Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oblaka-iac",
"version": "0.0.14",
"version": "0.0.15",
"main": "./src/index.ts",
"type": "module",
"license": "MIT",
Expand Down
13 changes: 13 additions & 0 deletions src/resources/d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ export class D1Database implements BindableResource<D1DatabaseState> {
}
}

// 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',
Expand Down
12 changes: 12 additions & 0 deletions src/resources/queues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ export class Queue implements BindableResource<QueueState> {
}
}

// 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',
Expand Down
13 changes: 13 additions & 0 deletions src/resources/r2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ export class R2Bucket implements BindableResource<R2BucketState> {
}
}

// 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',
Expand Down
63 changes: 37 additions & 26 deletions src/resources/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,31 @@ export class Worker implements BindableResource<WorkerState> {
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'
>
>,
) {}
Expand Down Expand Up @@ -86,6 +84,19 @@ export class Worker implements BindableResource<WorkerState> {
}
}

// 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`,
Expand Down
26 changes: 26 additions & 0 deletions tests/helpers/mock-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Context } from '../../src/types'

/**
* Builds a Context whose client.fetch is backed by a lookup table keyed by
* `"<METHOD> <url>"`. 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<string, unknown>, 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 }
}
33 changes: 33 additions & 0 deletions tests/resources/d1.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
Expand Down
32 changes: 32 additions & 0 deletions tests/resources/queues.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
Expand Down
32 changes: 32 additions & 0 deletions tests/resources/r2.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
Expand Down
49 changes: 49 additions & 0 deletions tests/resources/worker.test.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -14,6 +15,39 @@ const createWorker = (overrides?: Partial<ConstructorParameters<typeof Worker>[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()
Expand All @@ -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()
Expand Down
Loading