| 1 | /** |
| 2 | * Destroy/state consistency: a destroy that cannot see the stack's |
| 3 | * persisted state must FAIL LOUDLY — never report "no changes" and leak |
| 4 | * the stack's cloud resources. |
| 5 | * |
| 6 | * Regression coverage for the 2026-08-05 full-suite leak: two Cloudflare |
| 7 | * test stacks' `afterAll destroy(Stack)` planned "no changes" while their |
| 8 | * committed localState rows sat on disk, silently leaking 3 real workers. |
| 9 | * The destroy session behaved as if pointed at an empty state tree for its |
| 10 | * entire lifetime (plan list, row gets, and the final `deleteStack` all |
| 11 | * missed), so the engine now: |
| 12 | * |
| 13 | * 1. anchors the local state tree to the process's INITIAL working |
| 14 | * directory (captured at module load) so no session can be built |
| 15 | * against a different tree, |
| 16 | * 2. cross-checks directory-level NotFound answers in LocalState with a |
| 17 | * synchronous `existsSync` before degrading to "empty", |
| 18 | * 3. verifies after every destroy that the stage is actually empty, and |
| 19 | * fails with `StateStoreError` when rows survive. |
| 20 | */ |
| 21 | import { apply } from "@/Apply.ts"; |
| 22 | import { provideFreshArtifactStore } from "@/Artifacts"; |
| 23 | import * as Plan from "@/Plan.ts"; |
| 24 | import { Stack } from "@/Stack"; |
| 25 | import * as State from "@/State/index"; |
| 26 | import { makeLocalState } from "@/State/LocalState.ts"; |
| 27 | import { Stage } from "@/Stage.ts"; |
| 28 | import * as Test from "@/Test/Alchemy"; |
| 29 | import { expect } from "alchemy-test"; |
| 30 | import * as Cause from "effect/Cause"; |
| 31 | import * as Effect from "effect/Effect"; |
| 32 | import * as Exit from "effect/Exit"; |
| 33 | import * as FileSystem from "effect/FileSystem"; |
| 34 | import * as Layer from "effect/Layer"; |
| 35 | import { TestLayers, TestResourceHooks } from "../test.resources.ts"; |
| 36 | |
| 37 | const { test } = Test.make({ providers: TestLayers() }); |
| 38 | |
| 39 | const STAGE = "test"; |
| 40 | const instanceId = "852f6ec2e19b66589825efe14dca2971"; |
| 41 | |
| 42 | const row = (fqn: string): State.ResourceState => |
| 43 | ({ |
| 44 | instanceId, |
| 45 | providerVersion: 0, |
| 46 | logicalId: fqn, |
| 47 | fqn, |
| 48 | namespace: undefined, |
| 49 | resourceType: "Test.TestResource", |
| 50 | status: "created", |
| 51 | props: { string: "a" }, |
| 52 | attr: { |
| 53 | string: "a", |
| 54 | stringArray: [], |
| 55 | stableString: fqn, |
| 56 | stableArray: [fqn], |
| 57 | replaceString: undefined, |
| 58 | redacted: undefined, |
| 59 | redactedArray: undefined, |
| 60 | }, |
| 61 | bindings: [], |
| 62 | downstream: [], |
| 63 | }) as State.ResourceState; |
| 64 | |
| 65 | /** Run `Plan.destroy -> apply` against an explicit state layer. */ |
| 66 | const runDestroy = ( |
| 67 | stackName: string, |
| 68 | stateLayer: Layer.Layer<State.State, never, never>, |
| 69 | deleted: string[], |
| 70 | ) => |
| 71 | Plan.destroy({ name: stackName, stage: STAGE }).pipe( |
| 72 | Effect.flatMap(apply), |
| 73 | Effect.asVoid, |
| 74 | Effect.provide(stateLayer), |
| 75 | Effect.provide(TestLayers() as Layer.Layer<any, never, any>), |
| 76 | Effect.provide( |
| 77 | Layer.succeed(TestResourceHooks, { |
| 78 | delete: (id) => Effect.sync(() => void deleted.push(id)), |
| 79 | }), |
| 80 | ), |
| 81 | Effect.provide( |
| 82 | Layer.succeed(Stack, { |
| 83 | name: stackName, |
| 84 | stage: STAGE, |
| 85 | resources: {}, |
| 86 | bindings: {}, |
| 87 | actions: {}, |
| 88 | }), |
| 89 | ), |
| 90 | Effect.provide(Layer.succeed(Stage, STAGE)), |
| 91 | provideFreshArtifactStore, |
| 92 | ) as Effect.Effect<void, any, never>; |
| 93 | |
| 94 | const stateStoreErrorOf = ( |
| 95 | exit: Exit.Exit<unknown, unknown>, |
| 96 | ): State.StateStoreError | undefined => { |
| 97 | if (!Exit.isFailure(exit)) return undefined; |
| 98 | const error = Cause.squash(exit.cause); |
| 99 | return error instanceof State.StateStoreError ? error : undefined; |
| 100 | }; |
| 101 | |
| 102 | test( |
| 103 | "a destroy that cannot see committed state fails loudly instead of reporting success", |
| 104 | Effect.gen(function* () { |
| 105 | const stackName = "destroy-consistency-blind-session"; |
| 106 | const inner = yield* State.InMemoryService({ |
| 107 | [stackName]: { [STAGE]: { A: row("A") } }, |
| 108 | }); |
| 109 | |
| 110 | // Simulate the observed failure: the store answers "no state" for the |
| 111 | // whole destroy session (plan lists nothing, deleteStack silently |
| 112 | // deletes nothing) and only recovers afterwards — the shape of a |
| 113 | // transient state outage or a mispointed session. |
| 114 | let blind = true; |
| 115 | const flaky: State.StateService = { |
| 116 | ...inner, |
| 117 | list: (req) => (blind ? Effect.succeed([]) : inner.list(req)), |
| 118 | deleteStack: (req) => |
| 119 | blind |
| 120 | ? Effect.sync(() => { |
| 121 | // The outage "ends" right after the swallowed deleteStack, so |
| 122 | // the engine's post-destroy verification reads honest state. |
| 123 | blind = false; |
| 124 | }) |
| 125 | : inner.deleteStack(req), |
| 126 | }; |
| 127 | const flakyLayer = Layer.succeed(State.State, Effect.succeed(flaky)); |
| 128 | |
| 129 | const deleted: string[] = []; |
| 130 | const exit = yield* runDestroy(stackName, flakyLayer, deleted).pipe( |
| 131 | Effect.exit, |
| 132 | ); |
| 133 | |
| 134 | // The destroy must NOT report success: its plan saw no rows, so the |
| 135 | // provider never deleted the cloud resource — reporting success here |
| 136 | // is exactly the silent-leak bug. |
| 137 | const error = stateStoreErrorOf(exit); |
| 138 | expect(error?._tag).toBe("StateStoreError"); |
| 139 | expect(error?.message).toContain("state row(s) remain"); |
| 140 | expect(deleted).toEqual([]); |
| 141 | // The row survives for the next (healthy) destroy to reclaim. |
| 142 | expect( |
| 143 | yield* inner.get({ stack: stackName, stage: STAGE, fqn: "A" }), |
| 144 | ).toBeDefined(); |
| 145 | |
| 146 | // A healthy session then destroys normally: provider delete runs, the |
| 147 | // row is dropped, and the post-destroy verification passes. |
| 148 | yield* runDestroy(stackName, flakyLayer, deleted); |
| 149 | expect(deleted).toEqual(["A"]); |
| 150 | expect( |
| 151 | yield* inner.get({ stack: stackName, stage: STAGE, fqn: "A" }), |
| 152 | ).toBeUndefined(); |
| 153 | }), |
| 154 | { tags: ["unit", "local"] }, |
| 155 | ); |
| 156 | |
| 157 | test( |
| 158 | "local state is anchored to the process's initial cwd, not the cwd at store build", |
| 159 | Effect.gen(function* () { |
| 160 | const fs = yield* FileSystem.FileSystem; |
| 161 | const stackName = "destroy-consistency-cwd-anchor"; |
| 162 | const writer = yield* makeLocalState(); |
| 163 | yield* writer.set({ |
| 164 | stack: stackName, |
| 165 | stage: STAGE, |
| 166 | fqn: "A", |
| 167 | value: row("A"), |
| 168 | }); |
| 169 | |
| 170 | const tmp = yield* fs.makeTempDirectory({ prefix: "alchemy-cwd-anchor" }); |
| 171 | const original = yield* Effect.sync(() => process.cwd()); |
| 172 | yield* Effect.gen(function* () { |
| 173 | yield* Effect.sync(() => process.chdir(tmp)); |
| 174 | // A store built while the process cwd points elsewhere must still |
| 175 | // resolve the same state tree — a destroy building its store during |
| 176 | // a transient cwd excursion would otherwise see an empty tree and |
| 177 | // plan "no changes" over live state. |
| 178 | const reader = yield* makeLocalState(); |
| 179 | expect(yield* reader.list({ stack: stackName, stage: STAGE })).toEqual([ |
| 180 | "A", |
| 181 | ]); |
| 182 | }).pipe(Effect.ensuring(Effect.sync(() => process.chdir(original)))); |
| 183 | |
| 184 | yield* writer.deleteStack({ stack: stackName }); |
| 185 | }), |
| 186 | // mutates process-global cwd |
| 187 | { tags: ["unit", "local"], exclusive: true }, |
| 188 | ); |