diff --git a/packages/sandbox-sidecar/src/provisioner.test.ts b/packages/sandbox-sidecar/src/provisioner.test.ts new file mode 100644 index 000000000..b858c03cc --- /dev/null +++ b/packages/sandbox-sidecar/src/provisioner.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createAllocationStateStore, + type AllocationStateStore, +} from "./state-store"; +import { createSidecarProvisioner } from "./provisioner"; +import type { SidecarBackend } from "./backend"; + +const TOKEN = "s3cr3t-bootstrap-token"; + +let dataDir: string; +let store: AllocationStateStore; +let started: string[]; + +beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "sandbox-sidecar-provisioner-")); + store = createAllocationStateStore(join(dataDir, "state.json")); + started = []; +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +function fakeBackend(): SidecarBackend { + let counter = 0; + return { + async startUnit() { + counter += 1; + const externalRef = `unit-${String(counter)}`; + started.push(externalRef); + return externalRef; + }, + async stopUnit() {}, + async findUnitsByAllocation() { + return []; + }, + }; +} + +function baseEnsureRequest(overrides: Partial> = {}) { + return { + allocationId: "alloc-1", + generation: 0, + tenantId: "tenant-1", + anchorRunId: "run-1", + sidecarId: "sidecar-1", + token: TOKEN, + hubWebSocketUrl: "wss://hub.example.com/ws", + ...overrides, + } as never; +} + +function makeProvisioner(backend: SidecarBackend = fakeBackend()) { + return createSidecarProvisioner({ + id: "test-provisioner", + apiVersion: 1, + bindingFingerprint: "test", + capabilities: [], + backend, + store, + }); +} + +describe("ensure request validation", () => { + test("accepts generation 0, the allocation service's initial generation", async () => { + const provisioner = makeProvisioner(); + + const result = await provisioner.ensure( + baseEnsureRequest({ generation: 0 }), + ); + + expect(result).toEqual({ kind: "accepted", externalRef: "unit-1" }); + }); + + test("accepts a later generation", async () => { + const provisioner = makeProvisioner(); + + await provisioner.ensure(baseEnsureRequest({ generation: 0 })); + const result = await provisioner.ensure( + baseEnsureRequest({ generation: 1 }), + ); + + expect(result).toEqual({ kind: "accepted", externalRef: "unit-2" }); + }); + + test("rejects a negative generation", async () => { + const provisioner = makeProvisioner(); + + const result = await provisioner.ensure( + baseEnsureRequest({ generation: -1 }), + ); + + expect(result).toMatchObject({ + kind: "rejected", + code: "invalid_ensure_request", + }); + expect(started).toHaveLength(0); + }); + + test("rejects a non-integer generation", async () => { + const provisioner = makeProvisioner(); + + const result = await provisioner.ensure( + baseEnsureRequest({ generation: 1.5 }), + ); + + expect(result).toMatchObject({ + kind: "rejected", + code: "invalid_ensure_request", + }); + expect(started).toHaveLength(0); + }); + + test("still rejects a stale generation once fencing has observed a later one", async () => { + const provisioner = makeProvisioner(); + + await provisioner.ensure(baseEnsureRequest({ generation: 1 })); + const result = await provisioner.ensure( + baseEnsureRequest({ generation: 0 }), + ); + + expect(result).toMatchObject({ + kind: "rejected", + code: "stale_generation", + }); + expect(started).toHaveLength(1); + }); +}); + +describe("destroy request validation", () => { + test("accepts generation 0", async () => { + const provisioner = makeProvisioner(); + await provisioner.ensure(baseEnsureRequest({ generation: 0 })); + + const result = await provisioner.destroy({ + allocationId: "alloc-1", + sidecarId: "sidecar-1", + generation: 0, + }); + + expect(result).toEqual({ kind: "destroyed" }); + }); + + test("rejects a negative generation", async () => { + const provisioner = makeProvisioner(); + + const result = await provisioner.destroy({ + allocationId: "alloc-1", + sidecarId: "sidecar-1", + generation: -1, + }); + + expect(result).toMatchObject({ + kind: "rejected", + code: "invalid_destroy_request", + }); + }); +}); diff --git a/packages/sandbox-sidecar/src/provisioner.ts b/packages/sandbox-sidecar/src/provisioner.ts index 209d9e725..9771be319 100644 --- a/packages/sandbox-sidecar/src/provisioner.ts +++ b/packages/sandbox-sidecar/src/provisioner.ts @@ -218,8 +218,8 @@ function validateEnsureRequest(request: EnsureSidecarRequest): string | null { if (request.token === "") return "token must not be empty"; if (request.hubWebSocketUrl === "") return "hubWebSocketUrl must not be empty"; - if (!Number.isInteger(request.generation) || request.generation <= 0) { - return "generation must be a positive integer"; + if (!Number.isInteger(request.generation) || request.generation < 0) { + return "generation must be a non-negative integer"; } return null; } @@ -227,8 +227,8 @@ function validateEnsureRequest(request: EnsureSidecarRequest): string | null { function validateDestroyRequest(request: DestroySidecarRequest): string | null { if (request.allocationId === "") return "allocationId must not be empty"; if (request.sidecarId === "") return "sidecarId must not be empty"; - if (!Number.isInteger(request.generation) || request.generation <= 0) { - return "generation must be a positive integer"; + if (!Number.isInteger(request.generation) || request.generation < 0) { + return "generation must be a non-negative integer"; } return null; } diff --git a/packages/sandbox-sidecar/src/state-store.test.ts b/packages/sandbox-sidecar/src/state-store.test.ts index d9bdadffb..abd723afd 100644 --- a/packages/sandbox-sidecar/src/state-store.test.ts +++ b/packages/sandbox-sidecar/src/state-store.test.ts @@ -30,6 +30,16 @@ describe("observeEnsure", () => { expect(result.kind).toBe("observed"); }); + test("accepts generation 0, the allocation service's initial generation", async () => { + const store = createAllocationStateStore(statePath()); + const result = await store.observeEnsure({ + allocationId: "alloc-1", + sidecarId: "sidecar-1", + generation: 0, + }); + expect(result.kind).toBe("observed"); + }); + test("is idempotent when observed again at the same generation", async () => { const store = createAllocationStateStore(statePath()); await store.observeEnsure({ diff --git a/packages/sandbox-sidecar/src/state-store.ts b/packages/sandbox-sidecar/src/state-store.ts index 12e5bed2a..9e600e228 100644 --- a/packages/sandbox-sidecar/src/state-store.ts +++ b/packages/sandbox-sidecar/src/state-store.ts @@ -12,7 +12,7 @@ import { type } from "arktype"; const AllocationRecord = type({ allocationId: "string > 0", sidecarId: "string > 0", - generation: "number.integer > 0", + generation: "number.integer >= 0", desiredState: "'ensured' | 'destroyed'", externalRef: "string | null", tokenHashSha256: "string | null",