diff --git a/package.json b/package.json index bfd155b..5d1cbc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oblaka-iac", - "version": "0.0.14", + "version": "0.0.15", "main": "./src/index.ts", "type": "module", "license": "MIT", diff --git a/src/resources/d1.ts b/src/resources/d1.ts index 518f3af..abfa2b3 100644 --- a/src/resources/d1.ts +++ b/src/resources/d1.ts @@ -48,6 +48,19 @@ export class D1Database implements BindableResource { } } + // Adopt an existing database instead of creating a duplicate. D1 allows + // multiple databases with the same name, so on lost state a blind create + // would silently spawn a second, empty DB and the binding would point at + // it — data loss. Look it up by name first and adopt it. + const existingDatabases = await args.context.client.fetch<{ uuid: string; name: string }[]>({ + url: `/d1/database?name=${encodeURIComponent(remoteName)}`, + method: 'GET', + }) + const existingDatabase = existingDatabases.find(d => d.name === remoteName) + if (existingDatabase) { + return { name: remoteName, id: existingDatabase.uuid } + } + const result = await args.context.client.fetch<{ uuid: string }>({ url: `/d1/database`, method: 'POST', diff --git a/src/resources/queues.ts b/src/resources/queues.ts index dab40fa..a685db4 100644 --- a/src/resources/queues.ts +++ b/src/resources/queues.ts @@ -53,6 +53,18 @@ export class Queue implements BindableResource { } } + // Adopt an existing queue instead of failing on create. On lost state a + // blind create conflicts with the already-existing queue; look it up by + // name and adopt it. + const existingQueues = await args.context.client.fetch<{ queue_id: string; queue_name: string }[]>({ + url: `/queues?per_page=1000`, + method: 'GET', + }) + const existingQueue = existingQueues.find(q => q.queue_name === remoteName) + if (existingQueue) { + return { id: existingQueue.queue_id, name: remoteName } + } + const result = await args.context.client.fetch<{ queue_id: string }>({ url: `/queues`, method: 'POST', diff --git a/src/resources/r2.ts b/src/resources/r2.ts index f5c4abc..a407576 100644 --- a/src/resources/r2.ts +++ b/src/resources/r2.ts @@ -44,6 +44,19 @@ export class R2Bucket implements BindableResource { } } + // Adopt an existing bucket instead of failing on create. On lost state a + // blind create returns a conflict for the already-existing bucket; look + // it up by name and adopt it (bucket contents are untouched either way). + const existingBuckets = await args.context.client.fetch<{ buckets: { name: string }[] }>({ + url: `/r2/buckets?per_page=1000`, + method: 'GET', + }) + if (existingBuckets.buckets?.some(b => b.name === remoteName)) { + return { + name: remoteName, + } + } + await args.context.client.fetch({ url: `/r2/buckets`, method: 'POST', diff --git a/src/resources/worker.ts b/src/resources/worker.ts index a934ef8..c234939 100644 --- a/src/resources/worker.ts +++ b/src/resources/worker.ts @@ -15,33 +15,31 @@ export class Worker implements BindableResource { deleteDurableObjectsOnRemoval?: boolean } & Partial< - Pick< + Omit< Config, - | 'main' - | 'compatibility_date' - | 'vars' - | 'find_additional_modules' - | 'base_dir' - | 'preview_urls' - | 'workers_dev' - | 'routes' - | 'route' - | 'observability' - | 'logpush' - | 'rules' - | 'limits' - | 'no_bundle' - | 'keep_names' - | 'first_party_worker' - | 'minify' - | 'assets' - | 'compliance_region' - | 'build' - | 'define' - | 'jsx_factory' - | 'jsx_fragment' - | 'triggers' - | 'upload_source_maps' + | 'name' + | 'compatibility_flags' + | 'account_id' + | 'kv_namespaces' + | 'r2_buckets' + | 'd1_databases' + | 'queues' + | 'durable_objects' + | 'migrations' + | 'workflows' + | 'containers' + | 'send_email' + | 'services' + | 'vectorize' + | 'analytics_engine_datasets' + | 'browser' + | 'images' + | 'version_metadata' + | 'worker_loaders' + | 'vpc_networks' + | 'ai_search_namespaces' + | 'flagship' + | 'cache' > >, ) {} @@ -86,6 +84,19 @@ export class Worker implements BindableResource { } } + // Adopt an existing worker instead of failing on create. State can be + // lost (e.g. the shared cf-state KV namespace gets clobbered by another + // project deploying the same env), in which case a blind create returns + // 10040 "already exists". Look it up by name and adopt it. + const existingWorkers = await args.context.client.fetch<{ id: string; name: string }[]>({ + method: 'GET', + url: `/workers/workers?per_page=1000`, + }) + const existingWorker = existingWorkers.find(w => w.name === remoteName) + if (existingWorker) { + return { id: existingWorker.id, name: remoteName } + } + const result = await args.context.client.fetch<{ id: string }>({ method: 'POST', url: `/workers/workers`, diff --git a/tests/helpers/mock-client.ts b/tests/helpers/mock-client.ts new file mode 100644 index 0000000..aeb60e9 --- /dev/null +++ b/tests/helpers/mock-client.ts @@ -0,0 +1,26 @@ +import type { Context } from '../../src/types' + +/** + * Builds a Context whose client.fetch is backed by a lookup table keyed by + * `" "`. Exact matches win; otherwise the first key that is a + * prefix of the request matches. Every call is recorded in `calls` so tests can + * assert that, e.g., adopt-on-conflict skipped the create POST. + */ +export function mockContext(handlers: Record, env = 'production') { + const calls: { method: string; url: string }[] = [] + const client = { + async fetch({ method, url }: { method: string; url: string }) { + calls.push({ method, url }) + const signature = `${method} ${url}` + if (signature in handlers) { + return handlers[signature] + } + const prefix = Object.keys(handlers).find(key => signature.startsWith(key)) + if (prefix !== undefined) { + return handlers[prefix] + } + throw new Error(`unexpected request: ${signature}`) + }, + } + return { context: { env, client: client as unknown as Context['client'] } satisfies Context, calls } +} diff --git a/tests/resources/d1.test.ts b/tests/resources/d1.test.ts index 55ae376..50fa207 100644 --- a/tests/resources/d1.test.ts +++ b/tests/resources/d1.test.ts @@ -1,10 +1,43 @@ import { describe, expect, test } from 'bun:test' import { D1Database } from '../../src/resources/d1' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config describe('D1Database', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const db = new D1Database({ name: 'my-db' }) + const { context, calls } = mockContext({}) + const result = await db.apply({ state: { id: 'uuid-1', name: 'production-my-db' }, context, dryRun: false }) + expect(result).toEqual({ id: 'uuid-1', name: 'production-my-db' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote database by name instead of creating a duplicate', async () => { + const db = new D1Database({ name: 'my-db' }) + const { context, calls } = mockContext({ + 'GET /d1/database?name=production-my-db': [{ uuid: 'uuid-existing', name: 'production-my-db' }], + }) + const result = await db.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'uuid-existing', name: 'production-my-db' }) + // Crucially, no POST — a blind create would spawn a second empty DB. + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote database matches', async () => { + const db = new D1Database({ name: 'my-db' }) + const { context, calls } = mockContext({ + 'GET /d1/database?name=production-my-db': [], + 'POST /d1/database': { uuid: 'uuid-new' }, + }) + const result = await db.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'uuid-new', name: 'production-my-db' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/d1/database')).toBe(true) + }) + }) + describe('getId', () => { test('returns d1_database resource kind', () => { const db = new D1Database({ name: 'my-db' }) diff --git a/tests/resources/queues.test.ts b/tests/resources/queues.test.ts index f6f3cb5..6268a5e 100644 --- a/tests/resources/queues.test.ts +++ b/tests/resources/queues.test.ts @@ -1,10 +1,42 @@ import { describe, expect, test } from 'bun:test' import { Queue } from '../../src/resources/queues' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config describe('Queue', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const queue = new Queue({ name: 'my-queue' }) + const { context, calls } = mockContext({}) + const result = await queue.apply({ state: { id: 'q-1', name: 'production-my-queue' }, context, dryRun: false }) + expect(result).toEqual({ id: 'q-1', name: 'production-my-queue' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote queue by name instead of failing on create', async () => { + const queue = new Queue({ name: 'my-queue' }) + const { context, calls } = mockContext({ + 'GET /queues': [{ queue_id: 'q-existing', queue_name: 'production-my-queue' }], + }) + const result = await queue.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'q-existing', name: 'production-my-queue' }) + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote queue matches', async () => { + const queue = new Queue({ name: 'my-queue' }) + const { context, calls } = mockContext({ + 'GET /queues': [], + 'POST /queues': { queue_id: 'q-new' }, + }) + const result = await queue.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'q-new', name: 'production-my-queue' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/queues')).toBe(true) + }) + }) + describe('getId', () => { test('returns queue resource kind', () => { const queue = new Queue({ name: 'my-queue' }) diff --git a/tests/resources/r2.test.ts b/tests/resources/r2.test.ts index 856ab1e..4fa5a59 100644 --- a/tests/resources/r2.test.ts +++ b/tests/resources/r2.test.ts @@ -1,10 +1,42 @@ import { describe, expect, test } from 'bun:test' import { R2Bucket } from '../../src/resources/r2' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config describe('R2Bucket', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const r2 = new R2Bucket({ name: 'my-bucket' }) + const { context, calls } = mockContext({}) + const result = await r2.apply({ state: { name: 'production-my-bucket' }, context, dryRun: false }) + expect(result).toEqual({ name: 'production-my-bucket' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote bucket by name instead of failing on create', async () => { + const r2 = new R2Bucket({ name: 'my-bucket' }) + const { context, calls } = mockContext({ + 'GET /r2/buckets': { buckets: [{ name: 'production-my-bucket' }] }, + }) + const result = await r2.apply({ context, dryRun: false }) + expect(result).toEqual({ name: 'production-my-bucket' }) + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote bucket matches', async () => { + const r2 = new R2Bucket({ name: 'my-bucket' }) + const { context, calls } = mockContext({ + 'GET /r2/buckets': { buckets: [] }, + 'POST /r2/buckets': {}, + }) + const result = await r2.apply({ context, dryRun: false }) + expect(result).toEqual({ name: 'production-my-bucket' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/r2/buckets')).toBe(true) + }) + }) + describe('getId', () => { test('returns r2_bucket resource kind', () => { const r2 = new R2Bucket({ name: 'my-bucket' }) diff --git a/tests/resources/worker.test.ts b/tests/resources/worker.test.ts index ed62407..f7649b2 100644 --- a/tests/resources/worker.test.ts +++ b/tests/resources/worker.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { Worker } from '../../src/resources/worker' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config @@ -14,6 +15,39 @@ const createWorker = (overrides?: Partial[0 }) describe('Worker', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const worker = createWorker() + const { context, calls } = mockContext({}) + const result = await worker.apply({ state: { id: 'w-1', name: 'production-my-worker' }, context, dryRun: false }) + expect(result).toEqual({ id: 'w-1', name: 'production-my-worker' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote worker by name instead of failing on create', async () => { + const worker = createWorker() + const { context, calls } = mockContext({ + 'GET /workers/workers': [{ id: 'w-existing', name: 'production-my-worker' }], + }) + const result = await worker.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'w-existing', name: 'production-my-worker' }) + // No create POST — a blind create would return 10040 "already exists". + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote worker matches', async () => { + const worker = createWorker() + const { context, calls } = mockContext({ + 'GET /workers/workers': [], + 'POST /workers/workers': { id: 'w-new' }, + 'POST /workers/workers/w-new/versions': {}, + }) + const result = await worker.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'w-new', name: 'production-my-worker' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/workers/workers')).toBe(true) + }) + }) + describe('getId', () => { test('returns worker resource kind and name', () => { const worker = createWorker() @@ -35,6 +69,21 @@ describe('Worker', () => { }) }) + describe('constructor option types', () => { + test('accepts raw wrangler config fields that oblaka does not manage', () => { + const worker = createWorker({ compatibility_date: '2024-01-01', vars: { FOO: 'bar' } }) + expect(worker.getId()).toEqual({ resource: 'worker', id: 'my-worker' }) + }) + + test('rejects oblaka-managed binding fields', () => { + const worker = createWorker({ + // @ts-expect-error kv_namespaces is managed by the KV resource and must not be set directly + kv_namespaces: [{ binding: 'KV', id: 'abc' }], + }) + expect(worker.getId()).toEqual({ resource: 'worker', id: 'my-worker' }) + }) + }) + describe('configureBinding', () => { test('adds service binding for local env', () => { const worker = createWorker()