diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index 0954f2b1d7..f75f0df0d0 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -1,13 +1,22 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Deferred, Effect, Fiber, FileSystem, Layer, ManagedRuntime, Schedule } from "effect"; +import { + Cause, + Deferred, + Effect, + Fiber, + FileSystem, + Layer, + ManagedRuntime, + Schedule, +} from "effect"; import { HttpServer } from "effect/unstable/http"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager, managedStackManagerLayer } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl } from "./managed/control.ts"; +import { acquireControl, ControlTransport } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; @@ -304,6 +313,168 @@ describe("managed stack lifecycle journeys", () => { ); }); + it.live( + "keeps a stack document when its identity changes before delete ownership settles", + () => { + const { layer, workspace } = setup(); + const copied = join(workspace, "..", "delete-race-copy"); + let armed = false; + let markerSwapped!: Deferred.Deferred; + const gatedTransport = Layer.effect( + ControlTransport, + Effect.gen(function* () { + const base = yield* ControlTransport; + return { + ...base, + read: (endpoint: Parameters[0]) => + Effect.gen(function* () { + if (armed) { + armed = false; + writeFileSync( + join(copied, ".supabase", "identity.json"), + readFileSync(join(workspace, ".supabase", "identity.json")), + ); + yield* Deferred.succeed(markerSwapped, void 0); + } + return yield* base.read(endpoint); + }), + } satisfies typeof base; + }), + ).pipe(Layer.provide(controlTransportLayer)); + const managerLayer = layer.pipe(Layer.provide(gatedTransport)); + return Effect.scoped( + Effect.gen(function* () { + markerSwapped = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const originalEnvironment = yield* ensureEnvironment(workspace); + const originalStackId = deriveStackId(originalEnvironment.identity, "default"); + const originalOwner = yield* acquireControl({ stackId: originalStackId }); + if (originalOwner._tag !== "Owned") throw new Error("expected original ownership"); + const original = yield* manager.startStack({ + workspacePath: workspace, + portDocument: automaticDocument(), + ownership: originalOwner, + lifecycle: "stopped", + }); + yield* releaseLease(original); + yield* originalOwner.close; + + mkdirSync(copied); + const copiedEnvironment = yield* ensureEnvironment(copied); + const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); + const copiedOwner = yield* acquireControl({ stackId: copiedStackId }); + if (copiedOwner._tag !== "Owned") throw new Error("expected copied ownership"); + const copiedStack = yield* manager.startStack({ + workspacePath: copied, + portDocument: automaticDocument(), + ownership: copiedOwner, + lifecycle: "stopped", + }); + yield* releaseLease(copiedStack); + + armed = true; + const deleting = yield* Effect.forkScoped(deleteManagedStack({ workspacePath: copied })); + yield* Deferred.await(markerSwapped); + yield* copiedOwner.close; + const result = yield* Fiber.join(deleting).pipe(Effect.exit); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + expect(yield* manager.inspectStack(copiedStackId)).toBeDefined(); + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(gatedTransport), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + ); + }, + ); + + it.live("rejects stop when workspace identity changes while control ownership settles", () => { + const { layer, workspace } = setup(); + const copied = join(workspace, "..", "stop-race-copy"); + let armed = false; + let markerSwapped!: Deferred.Deferred; + const gatedTransport = Layer.effect( + ControlTransport, + Effect.gen(function* () { + const base = yield* ControlTransport; + return { + ...base, + read: (endpoint: Parameters[0]) => + Effect.gen(function* () { + if (armed) { + armed = false; + writeFileSync( + join(copied, ".supabase", "identity.json"), + readFileSync(join(workspace, ".supabase", "identity.json")), + ); + yield* Deferred.succeed(markerSwapped, void 0); + } + return yield* base.read(endpoint); + }), + } satisfies typeof base; + }), + ).pipe(Layer.provide(controlTransportLayer)); + const managerLayer = layer.pipe(Layer.provide(gatedTransport)); + return Effect.scoped( + Effect.gen(function* () { + markerSwapped = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const originalEnvironment = yield* ensureEnvironment(workspace); + const originalStackId = deriveStackId(originalEnvironment.identity, "default"); + const originalOwner = yield* acquireControl({ stackId: originalStackId }); + if (originalOwner._tag !== "Owned") throw new Error("expected original ownership"); + const original = yield* manager.startStack({ + workspacePath: workspace, + portDocument: automaticDocument(), + ownership: originalOwner, + lifecycle: "stopped", + }); + yield* releaseLease(original); + yield* originalOwner.close; + + mkdirSync(copied); + const copiedEnvironment = yield* ensureEnvironment(copied); + const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); + const copiedOwner = yield* acquireControl({ stackId: copiedStackId }); + if (copiedOwner._tag !== "Owned") throw new Error("expected copied ownership"); + const copiedStack = yield* manager.startStack({ + workspacePath: copied, + portDocument: automaticDocument(), + ownership: copiedOwner, + lifecycle: "starting", + }); + yield* releaseLease(copiedStack); + + armed = true; + const stopping = yield* Effect.forkScoped(stopManagedStack({ workspacePath: copied })); + yield* Deferred.await(markerSwapped); + yield* copiedOwner.close; + const result = yield* Fiber.join(stopping).pipe(Effect.exit); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + expect((yield* manager.inspectStack(copiedStackId))?.lifecycle).toBe("starting"); + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(gatedTransport), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(httpTransportClientLayer), + ); + }); + it.live("deletes an owned stack when its document path is a directory", () => { const { layer, stateRoot, workspace } = setup(); return Effect.scoped( diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index c14e1f8311..d88a09795f 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -1,16 +1,26 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"; -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; import { ManagedStackManager } from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { acquireControl, ControlTransport } from "./managed/control.ts"; +import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { controlTransportLayer } from "./platform-node.ts"; import { listStacks as listStackSummaries, resolveStackSummary } from "./discovery.ts"; -import { makeRepository } from "../tests/helpers/git-workspace.ts"; +import { git, makeRepository } from "../tests/helpers/git-workspace.ts"; import { automaticDocument, cleanupRoots, @@ -23,6 +33,94 @@ afterEach(() => cleanupRoots(roots)); const setup = () => setupManagedManager(roots); describe("managed stack projects journeys", () => { + it.live("rejects copied ordinary workspace identity until the original path is moved", () => { + const { layer, workspace } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const ownership = yield* acquireControl({ stackId }); + if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + const initial = yield* manager.startStack({ + workspacePath: workspace, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + yield* initial.lease.releaseAll; + + const copied = join(workspace, "..", "workspace-copy"); + cpSync(workspace, copied, { recursive: true }); + const copiedDiscovery = yield* manager.discoverWorkspace(copied).pipe(Effect.exit); + expect(Exit.isFailure(copiedDiscovery)).toBe(true); + if (Exit.isFailure(copiedDiscovery)) { + const error = Cause.squash(copiedDiscovery.cause); + expect(error).toMatchObject({ _tag: "InvalidManagedIdentityError" }); + if (error instanceof Error) { + expect(error.message).toContain("Delete"); + expect(error.message).toContain(".supabase/identity.json"); + } + } + const copiedEnsure = yield* manager.ensureWorkspace(copied).pipe(Effect.exit); + expect(Exit.isFailure(copiedEnsure)).toBe(true); + if (Exit.isFailure(copiedEnsure)) { + expect(Cause.squash(copiedEnsure.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + + const copiedStart = yield* manager + .startStack({ + workspacePath: copied, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }) + .pipe(Effect.exit); + expect(Exit.isFailure(copiedStart)).toBe(true); + if (Exit.isFailure(copiedStart)) { + const error = Cause.squash(copiedStart.cause); + expect(error).toMatchObject({ _tag: "InvalidManagedIdentityError" }); + if (error instanceof Error) { + expect(error.message).toContain("Delete"); + expect(error.message).toContain(".supabase/identity.json"); + } + } + + git(workspace, "init", "-q", "-b", "main"); + const copiedAfterOriginalGit = yield* manager.discoverWorkspace(copied); + expect(copiedAfterOriginalGit.state).toBe("ready"); + rmSync(join(workspace, ".git"), { recursive: true, force: true }); + + rmSync(copied, { recursive: true, force: true }); + renameSync(workspace, copied); + const movedDiscovery = yield* manager.discoverWorkspace(copied); + expect(movedDiscovery.state).toBe("ready"); + writeFileSync(workspace, "stale workspace path"); + const moved = yield* manager.startStack({ + workspacePath: copied, + stackName: "default", + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + expect(moved.stack.id).toBe(stackId); + expect(moved.stack.workspace.path).toBe(realpathSync(copied)); + expect((yield* manager.inspectStack(stackId))?.workspace.path).toBe(realpathSync(copied)); + yield* moved.lease.releaseAll; + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }); + it.live("rejects empty and ASCII-control stack names before resolving a stack", () => { const { layer, workspace } = setup(); return Effect.gen(function* () { diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index 4e9435154c..2bad6395ad 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -1,9 +1,11 @@ import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, ManagedRuntime } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime } from "effect"; import { HttpServer } from "effect/unstable/http"; +import { randomBytes } from "node:crypto"; import { chmodSync, + cpSync, mkdirSync, mkdtempSync, realpathSync, @@ -13,7 +15,12 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect } from "vitest"; -import { deriveRepairOwnershipId, ManagedStackManager } from "./managed/manager.ts"; +import { + deriveRepairOwnershipId, + ManagedStackManager, + type ManagedStackManagerShape, + managedStackManagerLayer, +} from "./managed/manager.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { acquireControl, ControlTransport } from "./managed/control.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; @@ -34,12 +41,145 @@ import { } from "../tests/helpers/managed-manager.ts"; const roots: Array = []; -const COLLIDING_STACK_A = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; -const COLLIDING_STACK_B = `${COLLIDING_STACK_A.slice(0, 10)}${"f".repeat(54)}`; afterEach(() => cleanupRoots(roots)); const setup = () => setupManagedManager(roots); +const acquireIsolatedCollisionOwner = () => + Effect.gen(function* () { + for (let attempt = 0; attempt < 32; attempt += 1) { + const stackId = randomBytes(32).toString("hex"); + const collidingStackId = `${stackId.slice(0, 10)}${randomBytes(27).toString("hex")}`; + const acquisition = yield* acquireControl({ stackId }).pipe( + Effect.timeout("1 second"), + Effect.exit, + ); + if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { + return { collidingStackId, ownership: acquisition.value }; + } + } + return yield* Effect.fail(new Error("failed to acquire an isolated collision endpoint")); + }); + +const acquireIsolatedStackOwner = (workspacePath: string) => + Effect.gen(function* () { + const environment = yield* ensureEnvironment(workspacePath); + for (let attempt = 0; attempt < 32; attempt += 1) { + const stackName = `test-${randomBytes(8).toString("hex")}`; + const stackId = deriveStackId(environment.identity, stackName); + const acquisition = yield* acquireControl({ stackId }).pipe( + Effect.timeout("1 second"), + Effect.exit, + ); + if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") { + return { stackName, ownership: acquisition.value }; + } + } + return yield* Effect.fail(new Error("failed to acquire an isolated stack endpoint")); + }); + +const startWithIsolatedOwner = ( + manager: ManagedStackManagerShape, + workspacePath: string, + portDocument: ReturnType, + lifecycle: "stopped" | "running" = "stopped", +) => + Effect.scoped( + Effect.gen(function* () { + const { stackName, ownership } = yield* acquireIsolatedStackOwner(workspacePath); + const result = yield* manager.startStack({ + workspacePath, + stackName, + portDocument, + ownership, + lifecycle, + }); + return { ...result, stackName }; + }), + ); + describe("managed stack recovery journeys", () => { + it.live("revalidates a copied ordinary workspace after a concurrent first start", () => { + const root = mkdtempSync(join(tmpdir(), "managed-manager-race-test-")); + roots.push(root); + const workspace = join(root, "workspace"); + const copied = join(root, "workspace-copy"); + const stateRoot = join(root, "state"); + mkdirSync(workspace); + const gate = { enabled: true, blocked: false }; + const gatedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem.FileSystem; + return { + ...base, + readFileString: (path: string, options?: Parameters[1]) => { + if (!gate.enabled || gate.blocked || !path.endsWith("stack.json")) { + return base.readFileString(path, options); + } + gate.blocked = true; + return Effect.gen(function* () { + yield* Deferred.succeed(readStarted, void 0); + yield* Deferred.await(releaseRead); + return yield* base.readFileString(path, options); + }); + }, + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeFileSystem.layer)); + const managerLayer = managedStackManagerLayer({ stateRoot }).pipe( + Layer.provide(gatedFileSystemLayer), + Layer.provide(NodePath.layer), + Layer.provide(gitConfigStoreLayer), + Layer.provide(controlTransportLayer), + ); + let readStarted!: Deferred.Deferred; + let releaseRead!: Deferred.Deferred; + return Effect.scoped( + Effect.gen(function* () { + readStarted = yield* Deferred.make(); + releaseRead = yield* Deferred.make(); + const manager = yield* ManagedStackManager; + const environment = yield* ensureEnvironment(workspace); + cpSync(workspace, copied, { recursive: true }); + const stackId = deriveStackId(environment.identity, "default"); + const ownership = yield* acquireControl({ stackId }); + if (ownership._tag !== "Owned") throw new Error("expected stack control ownership"); + + const readFiber = yield* Effect.forkScoped( + manager.readStack({ workspacePath: copied, portDocument: automaticDocument() }), + ); + yield* Deferred.await(readStarted); + const startFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const started = yield* manager.startStack({ + workspacePath: workspace, + portDocument: automaticDocument(), + ownership, + lifecycle: "stopped", + }); + yield* started.lease.releaseAll; + }), + ); + yield* Fiber.join(startFiber); + gate.enabled = false; + yield* Deferred.succeed(releaseRead, void 0); + const read = yield* Fiber.join(readFiber).pipe(Effect.exit); + expect(Exit.isFailure(read)).toBe(true); + if (Exit.isFailure(read)) { + expect(Cause.squash(read.cause)).toMatchObject({ + _tag: "InvalidManagedIdentityError", + }); + } + }), + ).pipe( + Effect.provide(managerLayer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + ); + }); + it.live("fences start publication while checkout repair is owned", () => { const { layer, workspace } = setup(); return Effect.gen(function* () { @@ -61,7 +201,6 @@ describe("managed stack recovery journeys", () => { Effect.gen(function* () { const manager = yield* ManagedStackManager; const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); const repairId = deriveRepairOwnershipId(environment.identity); const repairOwner = yield* acquireControl({ stackId: repairId }); if (repairOwner._tag !== "Owned") throw new Error("expected repair ownership"); @@ -73,13 +212,14 @@ describe("managed stack recovery journeys", () => { ), ); yield* Effect.promise(() => repairDaemon.runPromise(DaemonServer)); - const stackOwner = yield* acquireControl({ stackId }); - if (stackOwner._tag !== "Owned") throw new Error("expected stack ownership"); + const stackOwner = yield* acquireIsolatedStackOwner(workspace); + const stackId = deriveStackId(environment.identity, stackOwner.stackName); const startFiber = yield* manager .startStack({ workspacePath: workspace, + stackName: stackOwner.stackName, portDocument: automaticDocument(), - ownership: stackOwner, + ownership: stackOwner.ownership, }) .pipe(Effect.forkScoped); yield* Deferred.await(repairRead).pipe(Effect.timeout("1 second")); @@ -105,11 +245,10 @@ describe("managed stack recovery journeys", () => { return Effect.scoped( Effect.gen(function* () { const manager = yield* ManagedStackManager; - const ownership = yield* acquireControl({ stackId: COLLIDING_STACK_A }); - if (ownership._tag !== "Owned") throw new Error("expected ownership"); + const { collidingStackId, ownership } = yield* acquireIsolatedCollisionOwner(); const rejected = yield* manager .allocateManagedPorts(ownership, { - stackId: COLLIDING_STACK_B, + stackId: collidingStackId, portDocument: automaticDocument(), }) .pipe(Effect.exit); @@ -142,10 +281,11 @@ describe("managed stack recovery journeys", () => { mkdirSync(secondProject, { recursive: true }); const manager = yield* ManagedStackManager; const original = yield* Effect.scoped( - startWithOwner(manager, firstProject, automaticDocument(), "running"), + startWithIsolatedOwner(manager, firstProject, automaticDocument(), "running"), ); + const originalStackName = original.stackName; const secondary = yield* Effect.scoped( - startWithOwner(manager, secondProject, automaticDocument(), "stopped", "secondary"), + startWithIsolatedOwner(manager, secondProject, automaticDocument(), "stopped"), ); const originalId = original.stack.id; const originalPort = original.stack.ports[0]?.port; @@ -157,7 +297,11 @@ describe("managed stack recovery journeys", () => { const discovery = yield* manager.discoverWorkspace(movedFirstProject); if (discovery.state !== "needsRepair") throw new Error("expected repair"); const blockedRead = yield* manager - .readStack({ workspacePath: movedFirstProject, portDocument: automaticDocument() }) + .readStack({ + workspacePath: movedFirstProject, + stackName: originalStackName, + portDocument: automaticDocument(), + }) .pipe(Effect.exit); expect(Exit.isFailure(blockedRead)).toBe(true); if (Exit.isFailure(blockedRead)) { @@ -172,6 +316,7 @@ describe("managed stack recovery journeys", () => { } const deleteBeforeRepair = yield* deleteManagedStack({ workspacePath: movedFirstProject, + stackName: originalStackName, }).pipe(Effect.exit); expect(Exit.isFailure(deleteBeforeRepair)).toBe(true); const blockedId = [originalId, secondaryId].sort().at(-1); diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index 90cb704711..5387a01b25 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -11,6 +11,7 @@ import { ManagedStackAttachedError, ManagedStackControlRequiredError, ManagedStackManager, + ManagedWorkspaceRepairConflictError, workspaceRepairConflict, type ManagedStackManagerError, type ManagedStackLaunchUpdate, @@ -127,6 +128,14 @@ export const stopManagedStack = ( const document = yield* resolveManagedDocument(input); const stackId = document.id; const acquisition = yield* manager.acquireControl(stackId); + const revalidatedStackId = yield* stackIdForInput(manager, input); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before stop", + }), + ); + } if (acquisition._tag === "Owned") { if ( document.lifecycle === "running" || @@ -273,6 +282,14 @@ export const deleteManagedStack = ( ), Effect.mapError(() => new ManagedStackAttachedError({ stackId })), ); + const revalidatedStackId = yield* stackIdForInput(manager, input); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before delete", + }), + ); + } const result = yield* manager.deleteStack(stackId, acquisition); if (result.outcome === "already-absent") return yield* Effect.fail(noRunningStack(input)); }), diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 585ecaabfc..0db8766d52 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -41,10 +41,15 @@ import { type WorkspaceDiscovery, } from "./environment.ts"; import { GitConfigStore, inspectWorkspace } from "./git.ts"; -import { updateGitCheckoutLocationOwned } from "./identity.ts"; +import { + canonicalizeManagedWorkspacePathWithFileSystem, + readOrdinaryWorkspaceIdentityWithFileSystem, + updateGitCheckoutLocationOwned, +} from "./identity.ts"; import { ManagedExactPortOccupiedError, InvalidManagedStackNameError, + InvalidManagedIdentityError, ManagedPortAllocationError, ManagedStackNotFoundError, ManagedStackNotStoppedError, @@ -381,6 +386,59 @@ const makeManager = ( const store = yield* makeStackStore(stateRoot); const lifecycleLock = Semaphore.makeUnsafe(1); + const validateOrdinaryWorkspaceIdentity = ( + discovery: WorkspaceDiscovery, + ): Effect.Effect => + Effect.gen(function* () { + if (discovery.workspace.kind !== "folder") return; + const listings = yield* store.list(); + const matching = listings + .filter(isHealthyDocument) + .map((listing) => listing.document) + .filter( + (document) => + document.identity.workspaceId === discovery.identity.workspaceId && + document.identity.checkoutId === discovery.identity.checkoutId && + document.identity.contextId === discovery.identity.contextId && + document.identity.localProjectKey === discovery.identity.localProjectKey, + ); + for (const document of matching) { + const persistedPath = document.workspace.path; + const persistedInfo = yield* fileSystem + .stat(persistedPath) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (persistedInfo === undefined || persistedInfo.type !== "Directory") continue; + const canonicalPersistedPath = yield* provideDependencies( + canonicalizeManagedWorkspacePathWithFileSystem(persistedPath), + ); + if (canonicalPersistedPath === discovery.workspace.path) continue; + const persistedInspection = yield* provideDependencies( + inspectWorkspace(canonicalPersistedPath), + ); + if (persistedInspection.kind !== "ordinary-folder") continue; + const marker = yield* provideDependencies( + readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPersistedPath), + ); + if ( + marker !== undefined && + marker.workspaceId === discovery.identity.workspaceId && + marker.checkoutId === discovery.identity.checkoutId && + marker.contextId === discovery.identity.contextId + ) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: + "This ordinary workspace identity is already in use at another folder. Delete the current copied folder's .supabase/identity.json so a new identity can be generated.", + }), + ); + } + } + }); + const allocateManagedPorts = ( ownership: ControlOwnership, request: AllocateManagedPortsRequest, @@ -615,12 +673,14 @@ const makeManager = ( Effect.gen(function* () { const stackName = yield* validateManagedStackName(request.stackName ?? "default"); const discovery = yield* provideDependencies(discoverEnvironment(request.workspacePath)); + yield* validateOrdinaryWorkspaceIdentity(discovery); if (discovery.state === "needsRepair") { return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); } const stackId = deriveStackId(discovery.identity, stackName); const existing = yield* store.read(stackId); if (existing === undefined) return undefined; + yield* validateOrdinaryWorkspaceIdentity(discovery); const drift = stackDrift(existing, request.portDocument); return drift.length === 0 ? existing : { ...existing, drift }; }); @@ -635,6 +695,7 @@ const makeManager = ( Effect.gen(function* () { const stackName = yield* validateManagedStackName(request.stackName ?? "default"); const discovery = yield* provideDependencies(ensureEnvironment(request.workspacePath)); + yield* validateOrdinaryWorkspaceIdentity(discovery); if (discovery.state === "needsRepair") { return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); } @@ -660,6 +721,7 @@ const makeManager = ( ); return yield* Effect.gen(function* () { const refreshed = yield* provideDependencies(ensureEnvironment(request.workspacePath)); + yield* validateOrdinaryWorkspaceIdentity(refreshed); if (refreshed.state === "needsRepair") { return yield* Effect.fail(workspaceRepairConflict(refreshed.reason)); } @@ -910,8 +972,18 @@ const makeManager = ( return { stateRoot: store.stateRoot, - discoverWorkspace: (path) => provideDependencies(discoverEnvironment(path)), - ensureWorkspace: (path) => provideDependencies(ensureEnvironment(path)), + discoverWorkspace: (path) => + Effect.gen(function* () { + const discovery = yield* provideDependencies(discoverEnvironment(path)); + yield* validateOrdinaryWorkspaceIdentity(discovery); + return discovery; + }), + ensureWorkspace: (path) => + Effect.gen(function* () { + const discovery = yield* provideDependencies(ensureEnvironment(path)); + yield* validateOrdinaryWorkspaceIdentity(discovery); + return discovery; + }), acquireControl: (stackId) => provideDependencies(acquireControl({ stackId })), probeControl: (stackId) => provideDependencies(probeControl(stackId)), readStack, diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index da96e82da0..a87342d194 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -4,16 +4,20 @@ import { fork, type ChildProcess } from "node:child_process"; import { createServer as createHttpServer } from "node:http"; import { createConnection, createServer } from "node:net"; import { + cpSync, + chmodSync, existsSync, + type FSWatcher, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, + watch, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { describe, expect, test } from "vitest"; @@ -26,6 +30,7 @@ import { resolveConfig } from "./StackConfigResolver.ts"; import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; +import { git } from "../tests/helpers/git-workspace.ts"; const childEntryPoint = fileURLToPath( new URL("../tests/helpers/supervisor-child.ts", import.meta.url), @@ -34,6 +39,7 @@ const errorChildEntryPoint = fileURLToPath( new URL("../tests/helpers/supervisor-error-child.ts", import.meta.url), ); const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; +const FILE_WAIT_TIMEOUT_MS = 30_000; type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; @@ -48,31 +54,72 @@ const workspace = async (): Promise<{ readonly stateRoot: string; readonly stackId: string; }> => { - const root = mkdtempSync(join(tmpdir(), "sup-stack-workspace-")); - const stateRoot = mkdtempSync(join(tmpdir(), "sup-stack-state-")); - mkdirSync(join(root, ".supabase"), { recursive: true }); - const identity: EnvironmentIdentity = { - workspaceId: randomUUID(), - checkoutId: randomUUID(), - contextId: randomUUID(), - localProjectKey: ".", - }; - writeFileSync( - join(root, ".supabase", "identity.json"), - `${JSON.stringify( - { - version: 1, - workspaceId: identity.workspaceId, - checkoutId: identity.checkoutId, - contextId: identity.contextId, - }, - null, - 2, - )}\n`, - ); - return { root, stateRoot, stackId: deriveStackId(identity, "default") }; + for (let attempt = 0; attempt < 32; attempt += 1) { + const root = mkdtempSync(join(tmpdir(), "sup-stack-workspace-")); + const stateRoot = mkdtempSync(join(tmpdir(), "sup-stack-state-")); + const identity: EnvironmentIdentity = { + workspaceId: randomUUID(), + checkoutId: randomUUID(), + contextId: randomUUID(), + localProjectKey: ".", + }; + const stackId = deriveStackId(identity, "default"); + const endpoint = await Effect.runPromise(controlEndpoint(stackId)); + if (!(await canBind(endpoint.port))) { + rmSync(root, { recursive: true, force: true }); + rmSync(stateRoot, { recursive: true, force: true }); + continue; + } + mkdirSync(join(root, ".supabase"), { recursive: true }); + writeFileSync( + join(root, ".supabase", "identity.json"), + `${JSON.stringify( + { + version: 1, + workspaceId: identity.workspaceId, + checkoutId: identity.checkoutId, + contextId: identity.contextId, + }, + null, + 2, + )}\n`, + ); + return { root, stateRoot, stackId }; + } + throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts"); }; +const waitForFile = (path: string): Promise => + new Promise((resolve, reject) => { + if (existsSync(path)) { + resolve(); + return; + } + let settled = false; + let timeout: ReturnType | undefined; + let watcher: FSWatcher | undefined; + const settle = (continuation: () => void) => { + if (settled) return; + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + watcher?.close(); + continuation(); + }; + watcher = watch(dirname(path), () => { + if (existsSync(path)) settle(resolve); + }); + watcher.once("error", (cause) => { + settle(() => reject(cause)); + }); + timeout = setTimeout( + () => settle(() => reject(new Error(`timed out waiting for file ${path}`))), + FILE_WAIT_TIMEOUT_MS, + ); + if (existsSync(path)) { + settle(resolve); + } + }); + const messageFor = ( roots: { readonly root: string; @@ -199,6 +246,7 @@ const spawnChild = ( child.once("error", onError); child.once("exit", onExit); }); + void started.catch(() => undefined); void attachedBeforeReady.catch(() => undefined); child.send(input); return { child, started, attachedBeforeReady }; @@ -337,6 +385,7 @@ const listenOwnerSequence = async ( ownershipId: string, states: ReadonlyArray<"starting" | "stopping">, onRead: (state: "starting" | "stopping") => void = () => undefined, + closeAfterSequence = true, ): Promise> => { for (let attempt = 0; attempt < 100; attempt += 1) { let reads = 0; @@ -358,7 +407,7 @@ const listenOwnerSequence = async ( ready: false, }), () => { - if (reads >= states.length) server.close(); + if (closeAfterSequence && reads >= states.length) server.close(); }, ); }); @@ -379,6 +428,31 @@ const listenOwnerSequence = async ( throw new Error(`timed out binding fake owner at ${endpoint.url}`); }; +const listenStoppingOwner = async ( + endpoint: ControlEndpoint, + ownershipId: string, +): Promise<{ + readonly server: ReturnType; + readonly release: () => void; +}> => { + const server = await listenOwnerSequence( + endpoint, + ownershipId, + ["stopping"], + () => undefined, + false, + ); + let released = false; + return { + server, + release: () => { + if (released) return; + released = true; + server.close(); + }, + }; +}; + const cleanupRoots = (roots: { readonly root: string; readonly stateRoot: string }): void => { rmSync(roots.root, { recursive: true, force: true }); rmSync(roots.stateRoot, { recursive: true, force: true }); @@ -408,21 +482,58 @@ const readStackDocument = (roots: { return undefined; }; -const waitForStackDocument = async ( - roots: { readonly stateRoot: string }, - lifecycle: string, -): Promise<{ +type StackDocument = { readonly id: string; readonly lifecycle: string; readonly ports: ReadonlyArray<{ port: number }>; -}> => { - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - const document = readStackDocument(roots); - if (document?.lifecycle === lifecycle) return document; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`timed out waiting for stack document lifecycle ${lifecycle}`); + readonly launch?: { readonly mode: string; readonly versions: Record }; +}; + +const waitForStackDocument = async ( + roots: { readonly stateRoot: string; readonly stackId: string }, + lifecycle: string, +): Promise => { + const documentPath = managedStackDocumentPath(roots.stateRoot, roots.stackId); + const stackDirectory = dirname(documentPath); + await waitForFile(dirname(stackDirectory)); + await waitForFile(stackDirectory); + await waitForFile(documentPath); + const readDocument = (): StackDocument | undefined => { + try { + return JSON.parse(readFileSync(documentPath, "utf8")) as StackDocument; + } catch { + return undefined; + } + }; + const existing = readDocument(); + if (existing?.lifecycle === lifecycle) return existing; + + return new Promise((resolve, reject) => { + let watcher: FSWatcher | undefined; + let timeout: ReturnType | undefined; + let settled = false; + const settle = (continuation: () => void) => { + if (settled) return; + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + watcher?.close(); + continuation(); + }; + const check = () => { + const document = readDocument(); + if (document?.lifecycle === lifecycle) { + settle(() => resolve(document)); + } + }; + const fail = (cause: unknown) => settle(() => reject(cause)); + watcher = watch(stackDirectory, () => check()); + watcher.once("error", fail); + timeout = setTimeout( + () => fail(new Error(`timed out waiting for stack document lifecycle ${lifecycle}`)), + FILE_WAIT_TIMEOUT_MS, + ); + check(); + }); }; describe("detached supervisor child journeys", () => { @@ -633,10 +744,7 @@ describe("detached supervisor child journeys", () => { }); void child.started.catch(() => undefined); try { - const deadline = Date.now() + 2_000; - while (!existsSync(ensureReady) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(ensureReady); expect(existsSync(ensureReady)).toBe(true); const endpoint = await Effect.runPromise(controlEndpoint(roots.stackId)); const response = await fetch(`${endpoint.url}/stop`, { method: "POST" }); @@ -651,6 +759,97 @@ describe("detached supervisor child journeys", () => { } }); + test("rejects an attached contender whose copied workspace collides after owner validation", async () => { + const roots = await workspace(); + const copied = `${roots.root}-copy`; + const ensureReady = join(roots.root, "ensure-ready"); + const ensureRelease = join(roots.root, "ensure-release"); + const owner = spawnChild(messageFor(roots), { + environment: { + SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, + SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, + }, + }); + let contender: ChildHandle | undefined; + try { + await waitForFile(ensureReady); + expect(existsSync(ensureReady)).toBe(true); + + cpSync(roots.root, copied, { recursive: true }); + contender = spawnChild(messageFor(roots, { workspacePath: copied })); + await contender.attachedBeforeReady; + + writeFileSync(ensureRelease, "release"); + const started = await owner.started; + expect(started.attached).not.toBe(true); + await expect(contender.started).rejects.toThrow( + /ordinary workspace identity.*\.supabase\/identity\.json/, + ); + await remoteStop(started.endpoint); + await waitForExit(owner.child); + } finally { + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + rmSync(copied, { recursive: true, force: true }); + cleanupRoots(roots); + } + }); + + test("rejects a copied contender after dead-owner takeover before Docker cleanup", async () => { + const roots = await workspace(); + const copied = `${roots.root}-copy`; + const dockerBin = join(roots.root, "fake-docker-bin"); + const dockerSentinel = join(roots.root, "docker-called"); + mkdirSync(dockerBin); + const docker = join(dockerBin, "docker"); + writeFileSync(docker, `#!/bin/sh\nprintf called >> ${dockerSentinel}\n`); + chmodSync(docker, 0o755); + const owner = spawnChild(messageFor(roots), { testMode: "hold-start" }); + void owner.started.catch(() => undefined); + let contender: ChildHandle | undefined; + let fakeOwner: ReturnType | undefined; + let releaseFakeOwner: (() => void) | undefined; + try { + const starting = await waitForStackDocument(roots, "starting"); + const endpoint = await Effect.runPromise(controlEndpoint(starting.id)); + const stop = await fetch(`${endpoint.url}/stop`, { method: "POST" }); + expect(stop.status).toBe(202); + await waitForExit(owner.child); + expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); + + cpSync(roots.root, copied, { recursive: true }); + const originalGit = join(roots.root, ".git"); + git(roots.root, "init", "-q", "-b", "main"); + git(roots.root, "commit", "-q", "--allow-empty", "-m", "init"); + const stoppingOwner = await listenStoppingOwner(endpoint, starting.id); + fakeOwner = stoppingOwner.server; + releaseFakeOwner = stoppingOwner.release; + contender = spawnChild(messageFor(roots, { workspacePath: copied }), { + environment: { PATH: `${dockerBin}:${process.env.PATH ?? ""}` }, + }); + await contender.attachedBeforeReady; + + rmSync(originalGit, { recursive: true, force: true }); + const documentPath = managedStackDocumentPath(roots.stateRoot, starting.id); + const document = JSON.parse(readFileSync(documentPath, "utf8")) as Record; + writeFileSync(documentPath, JSON.stringify({ ...document, lifecycle: "running" })); + releaseFakeOwner?.(); + releaseFakeOwner = undefined; + fakeOwner = undefined; + + await expect(contender.started).rejects.toThrow( + /ordinary workspace identity.*\.supabase\/identity\.json/, + ); + expect(existsSync(dockerSentinel)).toBe(false); + } finally { + fakeOwner?.close(); + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + rmSync(copied, { recursive: true, force: true }); + cleanupRoots(roots); + } + }); + test("does not mark an existing stopped document failed when discovery fails after control bind", async () => { const roots = await workspace(); const initial = spawnChild(messageFor(roots)); @@ -839,20 +1038,33 @@ describe("detached supervisor child journeys", () => { test("bounds attached-owner recovery to one startup deadline", { timeout: 10_000 }, async () => { const roots = await workspace(); const input = messageFor(roots); - const environment = { SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400" }; - const owner = spawnChild(input, { testMode: "hold-start", environment }); + const attachedReady = join(roots.root, "attached-before-ready-ready"); + const attachedRelease = join(roots.root, "attached-before-ready-release"); + const owner = spawnChild(input, { + testMode: "hold-start", + environment: { SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400" }, + }); void owner.started.catch(() => undefined); let contender: ChildHandle | undefined; let fakeOwner: ReturnType | undefined; try { const document = await waitForStackDocument(roots, "starting"); const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - contender = spawnChild(input, { testMode: "hold-start", environment }); + contender = spawnChild(input, { + testMode: "hold-start", + environment: { + SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "400", + SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedReady, + SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE: attachedRelease, + }, + }); void contender.started.catch(() => undefined); await contender.attachedBeforeReady; + await waitForFile(attachedReady); await kill(owner.child); fakeOwner = await listenStartingOwner(endpoint, document.id); + writeFileSync(attachedRelease, "release"); await expect(contender.started).rejects.toMatchObject({ message: expect.stringContaining("Timed out resolving attached supervisor owner"), }); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index ca0aa68f0a..478b489ac9 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -414,6 +414,17 @@ const runManaged = ( : Effect.fail(error), ), ); + if (initialAcquisition._tag === "Attached") { + const revalidated = yield* manager.ensureWorkspace(input.workspacePath); + const revalidatedStackId = deriveStackId(revalidated.identity, input.stackName); + if (revalidatedStackId !== stackId) { + return yield* Effect.fail( + new SupervisorStartError({ + message: "Workspace identity changed before supervisor attach", + }), + ); + } + } if (acquisition._tag === "Attached") { yield* sendMessage({ type: "started", endpoint: acquisition.endpoint, attached: true }); process.disconnect?.(); diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 1ee26bc9e2..6f28597d78 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -2,7 +2,8 @@ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { BunFileSystem, BunServices } from "@effect/platform-bun"; import { Effect, Layer, Stream, Duration } from "effect"; import { createServer, type Server } from "node:net"; -import { existsSync, writeFileSync } from "node:fs"; +import { existsSync, type FSWatcher, watch, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { runSupervisor, SupervisorStartError, @@ -135,8 +136,40 @@ const testRuntime = ({ }); }; +const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { + const readyFile = process.env["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; + const releaseFile = process.env["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; + if (readyFile === undefined || releaseFile === undefined) return Effect.void; + return Effect.callback((resume) => { + let settled = false; + let watcher: FSWatcher | undefined; + const cleanup = () => { + watcher?.close(); + watcher = undefined; + }; + const settle = (result: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + resume(result); + }; + const resolveIfReleased = () => { + if (existsSync(releaseFile)) settle(Effect.void); + }; + try { + watcher = watch(dirname(releaseFile), () => resolveIfReleased()); + watcher.once("error", (cause) => settle(Effect.die(cause))); + writeFileSync(readyFile, "ready"); + resolveIfReleased(); + } catch (cause) { + settle(Effect.die(cause)); + } + return Effect.sync(cleanup); + }); +}; + const sendTestStage = (): Effect.Effect => - Effect.callback((resume) => { + Effect.callback((resume) => { if (process.send === undefined || !process.connected) { resume(Effect.void); return Effect.void; @@ -159,7 +192,7 @@ const sendTestStage = (): Effect.Effect => ); } return Effect.void; - }); + }).pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); const resolutionTimeout = (): Duration.Input => { const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]);