1import { Unowned } from "@/AdoptPolicy";
2import { Progress, type ApplyEvent, type ProgressEvent } from "@/Report.ts";
3import * as Namespace from "@/Namespace.ts";
4import { Stack } from "@/Stack";
5import { State, type ResourceState } from "@/State";
6import * as Drift from "@/Drift";
7import * as Test from "@/Test/Alchemy";
8import { describe, expect } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Layer from "effect/Layer";
13import {
14 Bucket,
15 DriftResource,
16 makeTestCloud,
17 ResourceFailure,
18 TestCloud,
19 TestLayers,
20 TestResource,
21 TestResourceHooks,
22 failOn,
23 type TestCloudService,
24} from "./test.resources.ts";
25
26const { test } = Test.make({ providers: TestLayers() });
27
28const getState = Effect.fn(function* <S = ResourceState>(resourceId: string) {
29 const state = yield* yield* State;
30 const stk = yield* Stack;
31 return (yield* state.get({
32 stack: stk.name,
33 stage: stk.stage,
34 fqn: resourceId,
35 })) as S;
36});
37
38const seed = Effect.fn(function* (rows: Record<string, any>) {
39 const state = yield* yield* State;
40 const stk = yield* Stack;
41 for (const [fqn, value] of Object.entries(rows)) {
42 yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
43 }
44});
45
46const runDrift = (
47 options: Drift.DriftOptions & { readonly dryRun?: boolean } = {},
48) =>
49 Effect.gen(function* () {
50 const stk = yield* Stack;
51 const identity = { name: stk.name, stage: stk.stage };
52 return yield* options.dryRun
53 ? Drift.detect(identity)
54 : Drift.repair(identity, { session: options.session });
55 });
56
57const withCloud =
58 (cloud: TestCloudService) =>
59 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
60 effect.pipe(Effect.provide(Layer.succeed(TestCloud, cloud)));
61
62const reconcileCalls = (cloud: TestCloudService) =>
63 cloud.calls.filter((c) => c.op === "reconcile").map((c) => c.id);
64
65const readCalls = (cloud: TestCloudService) =>
66 cloud.calls.filter((c) => c.op === "read").map((c) => c.id);
67
68const instanceId = "852f6ec2e19b66589825efe14dca2971";
69
70describe("drift detection and repair", { tags: ["unit", "local"] }, () => {
71 test.provider(
72 "clean deployment: every resource is unchanged and reconcile is not invoked",
73 (stack) => {
74 const cloud = makeTestCloud();
75 return Effect.gen(function* () {
76 yield* stack.deploy(
77 Effect.gen(function* () {
78 yield* DriftResource("A", { value: "a" });
79 yield* DriftResource("B", { value: "b" });
80 }),
81 );
82
83 cloud.calls.length = 0;
84 const result = yield* runDrift();
85
86 expect(result.resources).toMatchObject({
87 A: { action: "unchanged", resourceType: "Test.DriftResource" },
88 B: { action: "unchanged", resourceType: "Test.DriftResource" },
89 });
90 // drift observed both resources but touched neither
91 expect(readCalls(cloud).sort()).toEqual(["A", "B"]);
92 expect(reconcileCalls(cloud)).toEqual([]);
93 // cloud and state are untouched
94 expect(cloud.resources.get("A")).toMatchObject({ value: "a" });
95 expect((yield* getState("A"))?.status).toEqual("created");
96 }).pipe(withCloud(cloud));
97 },
98 );
99
100 test.provider(
101 "out-of-band value drift is repaired back to the desired state",
102 (stack) => {
103 const cloud = makeTestCloud();
104 return Effect.gen(function* () {
105 yield* stack.deploy(DriftResource("A", { value: "a" }));
106
107 // someone edits the resource in the console
108 cloud.resources.get("A")!.value = "hijacked";
109
110 cloud.calls.length = 0;
111 const result = yield* runDrift();
112
113 expect(result.resources.A).toMatchObject({
114 action: "repaired",
115 attr: { value: "a" },
116 });
117 // cloud converged back to the last-deployed desired state
118 expect(cloud.resources.get("A")).toMatchObject({ value: "a" });
119 expect(reconcileCalls(cloud)).toEqual(["A"]);
120 // state refreshed with the reconciled attributes
121 expect(yield* getState("A")).toMatchObject({
122 status: "updated",
123 attr: { value: "a" },
124 props: { value: "a" },
125 });
126 }).pipe(withCloud(cloud));
127 },
128 );
129
130 test.provider("nested tag drift is detected and repaired", (stack) => {
131 const cloud = makeTestCloud();
132 return Effect.gen(function* () {
133 yield* stack.deploy(
134 DriftResource("A", { value: "a", tags: { team: "alchemy" } }),
135 );
136
137 // foreign tags appear and an owned tag is clobbered
138 cloud.resources.get("A")!.tags = { team: "intruder", extra: "tag" };
139
140 const result = yield* runDrift();
141
142 expect(result.resources.A?.action).toEqual("repaired");
143 expect(cloud.resources.get("A")!.tags).toEqual({ team: "alchemy" });
144 expect((yield* getState("A"))?.attr?.tags).toEqual({ team: "alchemy" });
145 }).pipe(withCloud(cloud));
146 });
147
148 test.provider(
149 "a resource deleted out-of-band is recreated under the same instance id",
150 (stack) => {
151 const cloud = makeTestCloud();
152 return Effect.gen(function* () {
153 yield* stack.deploy(DriftResource("A", { value: "a" }));
154 const before = yield* getState("A");
155
156 // someone deletes the resource in the console
157 cloud.resources.delete("A");
158
159 const result = yield* runDrift();
160
161 expect(result.resources.A).toMatchObject({
162 action: "recreated",
163 attr: { id: "A", value: "a" },
164 });
165 expect(cloud.resources.get("A")).toMatchObject({ value: "a" });
166 expect(yield* getState("A")).toMatchObject({
167 status: "created",
168 // same instance id so deterministic physical names converge
169 instanceId: before!.instanceId,
170 attr: { value: "a" },
171 });
172 }).pipe(withCloud(cloud));
173 },
174 );
175
176 test.provider("drift is idempotent", (stack) => {
177 const cloud = makeTestCloud();
178 return Effect.gen(function* () {
179 yield* stack.deploy(DriftResource("A", { value: "a" }));
180 cloud.resources.get("A")!.value = "hijacked";
181
182 const first = yield* runDrift();
183 expect(first.resources.A?.action).toEqual("repaired");
184
185 cloud.calls.length = 0;
186 const second = yield* runDrift();
187 expect(second.resources.A?.action).toEqual("unchanged");
188 expect(reconcileCalls(cloud)).toEqual([]);
189 }).pipe(withCloud(cloud));
190 });
191
192 test.provider(
193 "mixed fleet: only drifted and missing resources are reconciled",
194 (stack) => {
195 const cloud = makeTestCloud();
196 return Effect.gen(function* () {
197 yield* stack.deploy(
198 Effect.gen(function* () {
199 yield* DriftResource("Clean", { value: "clean" });
200 yield* DriftResource("Drifted", { value: "drifted" });
201 yield* DriftResource("Missing", { value: "missing" });
202 }),
203 );
204
205 cloud.resources.get("Drifted")!.value = "hijacked";
206 cloud.resources.delete("Missing");
207
208 cloud.calls.length = 0;
209 const result = yield* runDrift();
210
211 expect(result.resources).toMatchObject({
212 Clean: { action: "unchanged" },
213 Drifted: { action: "repaired" },
214 Missing: { action: "recreated" },
215 });
216 expect(reconcileCalls(cloud).sort()).toEqual(["Drifted", "Missing"]);
217 expect(cloud.resources.get("Drifted")).toMatchObject({
218 value: "drifted",
219 });
220 expect(cloud.resources.get("Missing")).toMatchObject({
221 value: "missing",
222 });
223 }).pipe(withCloud(cloud));
224 },
225 );
226
227 test.provider("empty state: drift succeeds with no resources", () =>
228 Effect.gen(function* () {
229 const result = yield* runDrift();
230 expect(result.resources).toEqual({});
231 }),
232 );
233
234 test.provider(
235 "a provider whose read reflects state back never drifts",
236 (stack) =>
237 Effect.gen(function* () {
238 // TestResource.read returns the persisted output when no hook is
239 // installed — the degenerate "cannot observe drift" case.
240 yield* stack.deploy(TestResource("A", { string: "test-string" }));
241 const result = yield* runDrift();
242 expect(result.resources.A?.action).toEqual("unchanged");
243 }),
244 );
245});
246
247describe("dry run", { tags: ["unit", "local"] }, () => {
248 test.provider(
249 "reports drift without repairing the cloud or touching state",
250 (stack) => {
251 const cloud = makeTestCloud();
252 return Effect.gen(function* () {
253 yield* stack.deploy(DriftResource("A", { value: "a" }));
254 cloud.resources.get("A")!.value = "hijacked";
255
256 cloud.calls.length = 0;
257 const result = yield* runDrift({ dryRun: true });
258
259 expect(result.resources.A).toMatchObject({
260 action: "drifted",
261 // dry-run reports the OBSERVED (drifted) attributes
262 attr: { value: "hijacked" },
263 });
264 expect(reconcileCalls(cloud)).toEqual([]);
265 // neither the cloud nor the state store were touched
266 expect(cloud.resources.get("A")).toMatchObject({ value: "hijacked" });
267 expect(yield* getState("A")).toMatchObject({
268 status: "created",
269 attr: { value: "a" },
270 });
271 }).pipe(withCloud(cloud));
272 },
273 );
274
275 test.provider(
276 "reports missing resources without recreating them",
277 (stack) => {
278 const cloud = makeTestCloud();
279 return Effect.gen(function* () {
280 yield* stack.deploy(DriftResource("A", { value: "a" }));
281 cloud.resources.delete("A");
282
283 cloud.calls.length = 0;
284 const result = yield* runDrift({ dryRun: true });
285
286 expect(result.resources.A?.action).toEqual("missing");
287 expect(reconcileCalls(cloud)).toEqual([]);
288 expect(cloud.resources.has("A")).toBe(false);
289 // state still remembers the last-deployed attributes
290 expect((yield* getState("A"))?.attr).toMatchObject({ value: "a" });
291 }).pipe(withCloud(cloud));
292 },
293 );
294
295 test.provider("reports unchanged resources as unchanged", (stack) => {
296 const cloud = makeTestCloud();
297 return Effect.gen(function* () {
298 yield* stack.deploy(DriftResource("A", { value: "a" }));
299 const result = yield* runDrift({ dryRun: true });
300 expect(result.resources.A?.action).toEqual("unchanged");
301 }).pipe(withCloud(cloud));
302 });
303});
304
305describe("skipped resources", { tags: ["unit", "local"] }, () => {
306 test.provider("a provider without read is skipped with a reason", (stack) =>
307 Effect.gen(function* () {
308 yield* stack.deploy(Bucket("MyBucket", { name: "test-bucket" }));
309
310 const result = yield* runDrift();
311
312 expect(result.resources.MyBucket).toMatchObject({
313 action: "skipped",
314 resourceType: "Test.Bucket",
315 });
316 expect(result.resources.MyBucket?.reason).toContain("read");
317 // skipping is not destructive
318 expect((yield* getState("MyBucket"))?.status).toEqual("created");
319 }),
320 );
321
322 test.provider("resources stuck in a non-terminal status are skipped", () => {
323 const cloud = makeTestCloud();
324 return Effect.gen(function* () {
325 yield* seed({
326 Stuck: {
327 status: "creating",
328 fqn: "Stuck",
329 logicalId: "Stuck",
330 instanceId,
331 resourceType: "Test.DriftResource",
332 namespace: undefined,
333 providerVersion: 0,
334 props: { value: "v" },
335 bindings: [],
336 downstream: [],
337 },
338 });
339
340 const result = yield* runDrift();
341
342 expect(result.resources.Stuck?.action).toEqual("skipped");
343 expect(result.resources.Stuck?.reason).toContain("creating");
344 // no lifecycle operation ran against the unstable resource
345 expect(cloud.calls).toEqual([]);
346 }).pipe(withCloud(cloud));
347 });
348
349 test.provider("resources with a pending replacement chain are skipped", () =>
350 Effect.gen(function* () {
351 const oldGeneration = {
352 status: "created",
353 fqn: "R",
354 logicalId: "R",
355 instanceId: "00000000000000000000000000000000",
356 resourceType: "Test.DriftResource",
357 namespace: undefined,
358 providerVersion: 0,
359 props: { value: "v1" },
360 attr: { id: "R", value: "v1", tags: {}, env: {} },
361 bindings: [],
362 downstream: [],
363 };
364 yield* seed({
365 R: {
366 status: "replaced",
367 fqn: "R",
368 logicalId: "R",
369 instanceId,
370 resourceType: "Test.DriftResource",
371 namespace: undefined,
372 providerVersion: 0,
373 props: { value: "v2" },
374 attr: { id: "R", value: "v2", tags: {}, env: {} },
375 bindings: [],
376 downstream: [],
377 deleteFirst: false,
378 old: oldGeneration,
379 },
380 });
381
382 const result = yield* runDrift();
383
384 expect(result.resources.R?.action).toEqual("skipped");
385 expect(result.resources.R?.reason).toContain("replacement");
386 }),
387 );
388
389 test.provider("action state rows are ignored", (stack) => {
390 const cloud = makeTestCloud();
391 return Effect.gen(function* () {
392 yield* stack.deploy(DriftResource("A", { value: "a" }));
393 yield* seed({
394 MyTask: {
395 kind: "action",
396 status: "ran",
397 fqn: "MyTask",
398 logicalId: "MyTask",
399 actionType: "Test.Task",
400 namespace: undefined,
401 inputHash: "abc",
402 input: {},
403 output: { ok: true },
404 downstream: [],
405 },
406 });
407
408 const result = yield* runDrift();
409
410 expect(result.resources.MyTask).toBeUndefined();
411 expect(result.resources.A?.action).toEqual("unchanged");
412 // the action row survives untouched
413 expect(yield* getState("MyTask")).toMatchObject({
414 kind: "action",
415 status: "ran",
416 });
417 }).pipe(withCloud(cloud));
418 });
419});
420
421describe("failures", { tags: ["unit", "local"] }, () => {
422 test.provider(
423 "a failing repair does not prevent sibling repairs, and fails the drift",
424 (stack) => {
425 const cloud = makeTestCloud();
426 return Effect.gen(function* () {
427 yield* stack.deploy(
428 Effect.gen(function* () {
429 yield* DriftResource("A", { value: "a" });
430 yield* DriftResource("B", { value: "b" });
431 }),
432 );
433
434 cloud.resources.get("A")!.value = "hijacked-a";
435 cloud.resources.get("B")!.value = "hijacked-b";
436
437 // drift repair goes through the provider's update intent
438 const exit = yield* runDrift().pipe(
439 Effect.provide(
440 Layer.succeed(TestResourceHooks, failOn("B", "update")),
441 ),
442 Effect.exit,
443 );
444
445 expect(Exit.isFailure(exit)).toBe(true);
446 if (Exit.isFailure(exit)) {
447 const rendered = Cause.pretty(exit.cause);
448 expect(rendered).toContain(
449 "Resource 'B' (Test.DriftResource) failed during update",
450 );
451 expect(rendered).toContain("Failed to create");
452 }
453 // A was still repaired even though B failed
454 expect(cloud.resources.get("A")).toMatchObject({ value: "a" });
455 expect(yield* getState("A")).toMatchObject({
456 status: "updated",
457 attr: { value: "a" },
458 });
459 // B's cloud state is still drifted and its state row untouched
460 expect(cloud.resources.get("B")).toMatchObject({
461 value: "hijacked-b",
462 });
463 expect(yield* getState("B")).toMatchObject({
464 status: "created",
465 attr: { value: "b" },
466 });
467
468 // a follow-up drift (without the failure) converges B too
469 const result = yield* runDrift();
470 expect(result.resources).toMatchObject({
471 A: { action: "unchanged" },
472 B: { action: "repaired" },
473 });
474 expect(cloud.resources.get("B")).toMatchObject({ value: "b" });
475 }).pipe(withCloud(cloud));
476 },
477 );
478
479 test.provider(
480 "a failing recreate goes through the provider's create intent",
481 (stack) => {
482 const cloud = makeTestCloud();
483 return Effect.gen(function* () {
484 yield* stack.deploy(DriftResource("A", { value: "a" }));
485 cloud.resources.delete("A");
486
487 const exit = yield* runDrift().pipe(
488 Effect.provide(
489 Layer.succeed(TestResourceHooks, failOn("A", "create")),
490 ),
491 Effect.exit,
492 );
493
494 expect(Exit.isFailure(exit)).toBe(true);
495 expect(cloud.resources.has("A")).toBe(false);
496
497 // recovery: the next drift recreates it
498 const result = yield* runDrift();
499 expect(result.resources.A?.action).toEqual("recreated");
500 expect(cloud.resources.get("A")).toMatchObject({ value: "a" });
501 }).pipe(withCloud(cloud));
502 },
503 );
504
505 test.provider("a failing read fails the drift", (stack) =>
506 Effect.gen(function* () {
507 yield* stack.deploy(TestResource("A", { string: "test-string" }));
508
509 const exit = yield* runDrift().pipe(
510 Effect.provide(
511 Layer.succeed(TestResourceHooks, {
512 read: () => Effect.fail(new ResourceFailure("read exploded")),
513 }),
514 ),
515 Effect.exit,
516 );
517
518 expect(Exit.isFailure(exit)).toBe(true);
519 if (Exit.isFailure(exit)) {
520 const rendered = Cause.pretty(exit.cause);
521 expect(rendered).toContain(
522 "Resource 'A' (Test.TestResource) failed during read",
523 );
524 expect(rendered).toContain("read exploded");
525 }
526 // state untouched by the failed observation
527 expect(yield* getState("A")).toMatchObject({
528 status: "created",
529 attr: { string: "test-string" },
530 });
531 }),
532 );
533});
534
535describe("ownership", { tags: ["unit", "local"] }, () => {
536 test.provider(
537 "an unowned read result with matching attributes is unchanged",
538 (stack) => {
539 const cloud = makeTestCloud();
540 return Effect.gen(function* () {
541 yield* stack.deploy(DriftResource("A", { value: "a" }));
542
543 // ownership markers drifted but the attributes still match — the
544 // Unowned brand is a plan-time routing hint, not drift by itself
545 cloud.unowned.add("A");
546
547 cloud.calls.length = 0;
548 const result = yield* runDrift();
549
550 expect(result.resources.A?.action).toEqual("unchanged");
551 expect(reconcileCalls(cloud)).toEqual([]);
552 }).pipe(withCloud(cloud));
553 },
554 );
555
556 test.provider(
557 "an unowned, drifted resource is repaired and the brand never persists",
558 (stack) => {
559 const cloud = makeTestCloud();
560 return Effect.gen(function* () {
561 yield* stack.deploy(DriftResource("A", { value: "a" }));
562
563 cloud.unowned.add("A");
564 cloud.resources.get("A")!.value = "hijacked";
565
566 const result = yield* runDrift();
567
568 expect(result.resources.A?.action).toEqual("repaired");
569 expect(cloud.resources.get("A")).toMatchObject({ value: "a" });
570 const state = yield* getState("A");
571 expect(state).toMatchObject({
572 status: "updated",
573 attr: { value: "a" },
574 });
575 expect(Unowned.is(state!.attr)).toBe(false);
576 }).pipe(withCloud(cloud));
577 },
578 );
579});
580
581describe("bindings", { tags: ["unit", "local"] }, () => {
582 test.provider(
583 "binding-derived attributes are repaired from the persisted bindings",
584 (stack) => {
585 const cloud = makeTestCloud();
586 return Effect.gen(function* () {
587 const created = yield* stack.deploy(
588 Effect.gen(function* () {
589 const a = yield* DriftResource("A", { value: "a" });
590 yield* a.bind("Env", {
591 env: { FEATURE_FLAG: "on" },
592 });
593 return a;
594 }),
595 );
596 expect(created.env).toEqual({ FEATURE_FLAG: "on" });
597
598 // the binding-derived env is wiped out-of-band
599 cloud.resources.get("A")!.env = {};
600
601 const result = yield* runDrift();
602
603 expect(result.resources.A?.action).toEqual("repaired");
604 // reconcile received the persisted bindings and restored the env
605 expect(cloud.resources.get("A")!.env).toEqual({ FEATURE_FLAG: "on" });
606 expect((yield* getState("A"))?.attr?.env).toEqual({
607 FEATURE_FLAG: "on",
608 });
609 }).pipe(withCloud(cloud));
610 },
611 );
612});
613
614describe("plan", { tags: ["unit", "local"] }, () => {
615 test.provider(
616 "projects detection results onto plan node actions",
617 (stack) => {
618 const cloud = makeTestCloud();
619 return Effect.gen(function* () {
620 yield* stack.deploy(
621 Effect.gen(function* () {
622 yield* DriftResource("Clean", { value: "clean" });
623 yield* DriftResource("Drifted", { value: "drifted" });
624 yield* DriftResource("Missing", { value: "missing" });
625 }),
626 );
627
628 cloud.resources.get("Drifted")!.value = "hijacked";
629 cloud.resources.delete("Missing");
630
631 cloud.calls.length = 0;
632 const stk = yield* Stack;
633 const { result, plan } = yield* Drift.plan({
634 name: stk.name,
635 stage: stk.stage,
636 });
637
638 // drifted -> update, missing -> create, unchanged -> noop
639 expect(plan.resources.Clean?.action).toEqual("noop");
640 expect(plan.resources.Clean?.drift).toBeUndefined();
641 expect(plan.resources.Drifted?.action).toEqual("update");
642 expect(plan.resources.Missing?.action).toEqual("create");
643 expect(plan.resources.Drifted).toMatchObject({
644 drift: {
645 expected: { value: "drifted" },
646 actual: { value: "hijacked" },
647 },
648 });
649 expect(plan.resources.Missing).toMatchObject({
650 drift: { expected: { value: "missing" }, missing: true },
651 });
652 // detection result rides along
653 expect(result.resources).toMatchObject({
654 Clean: { action: "unchanged" },
655 Drifted: { action: "drifted" },
656 Missing: { action: "missing" },
657 });
658 // the synthetic resource carries what the renderers need
659 expect(plan.resources.Drifted?.resource).toMatchObject({
660 FQN: "Drifted",
661 LogicalId: "Drifted",
662 Type: "Test.DriftResource",
663 });
664 // resource-only view: no deletions/actions in a drift plan
665 expect(plan.deletions).toEqual({});
666 expect(plan.actions).toEqual({});
667 expect(plan.actionDeletions).toEqual({});
668 // planning is detection-only: nothing was repaired
669 expect(cloud.resources.get("Drifted")).toMatchObject({
670 value: "hijacked",
671 });
672 expect(reconcileCalls(cloud)).toEqual([]);
673 }).pipe(withCloud(cloud));
674 },
675 );
676
677 test.provider(
678 "reports each observed resource through the ambient Progress reporter",
679 (stack) => {
680 const cloud = makeTestCloud();
681 return Effect.gen(function* () {
682 yield* stack.deploy(
683 Effect.gen(function* () {
684 yield* DriftResource("Clean", { value: "clean" });
685 yield* DriftResource("Drifted", { value: "drifted" });
686 }),
687 );
688 cloud.resources.get("Drifted")!.value = "hijacked";
689
690 const events: Array<ProgressEvent> = [];
691 const stk = yield* Stack;
692 yield* Drift.plan({ name: stk.name, stage: stk.stage }).pipe(
693 Effect.provideService(Progress, (event) =>
694 Effect.sync(() => {
695 events.push(event);
696 }),
697 ),
698 );
699
700 // Phase markers bracket the observation fan-out.
701 expect(
702 events
703 .filter((event) => event._tag === "plan.phase")
704 .map(({ phase }) => phase),
705 ).toEqual(["loading-state", "computing-plan"]);
706 // Every read emits a start (so renderers can show in-flight rows)
707 // paired with a completion.
708 expect(
709 events
710 .filter((event) => event._tag === "plan.resource.started")
711 .map(({ logicalId }) => logicalId)
712 .sort(),
713 ).toEqual(["Clean", "Drifted"]);
714 const nodes = events.filter(
715 (event) => event._tag === "plan.resource.completed",
716 );
717 expect(nodes).toHaveLength(2);
718 expect(nodes.map(({ completed }) => completed).sort()).toEqual([1, 2]);
719 expect(
720 Object.fromEntries(
721 nodes.map((event) => [event.logicalId, event.action]),
722 ),
723 ).toEqual({ Clean: "noop", Drifted: "update" });
724 for (const event of nodes) {
725 expect(event).toMatchObject({
726 _tag: "plan.resource.completed",
727 total: 2,
728 });
729 }
730 }).pipe(withCloud(cloud));
731 },
732 );
733
734 test.provider(
735 "skipped resources render as noop nodes carrying persisted state",
736 (stack) =>
737 Effect.gen(function* () {
738 yield* stack.deploy(Bucket("MyBucket", { name: "test-bucket" }));
739
740 const stk = yield* Stack;
741 const { result, plan } = yield* Drift.plan({
742 name: stk.name,
743 stage: stk.stage,
744 });
745
746 expect(result.resources.MyBucket?.action).toEqual("skipped");
747 expect(plan.resources.MyBucket).toMatchObject({
748 action: "noop",
749 state: { status: "created" },
750 resource: { LogicalId: "MyBucket", Type: "Test.Bucket" },
751 });
752 }),
753 );
754
755 test.provider(
756 "namespaced resources keep their namespace, FQN and bindings",
757 (stack) => {
758 const cloud = makeTestCloud();
759 return Effect.gen(function* () {
760 const Site = (id: string) =>
761 Effect.gen(function* () {
762 const a = yield* DriftResource("A", { value: "a" });
763 yield* a.bind("Env", { env: { KEY: "on" } });
764 return a;
765 }).pipe(Namespace.push(id));
766
767 yield* stack.deploy(Site("Site"));
768
769 cloud.resources.get("A")!.value = "hijacked";
770
771 const stk = yield* Stack;
772 const { plan } = yield* Drift.plan({
773 name: stk.name,
774 stage: stk.stage,
775 });
776
777 const node = plan.resources["Site/A"];
778 expect(node?.action).toEqual("update");
779 expect(node?.resource).toMatchObject({
780 FQN: "Site/A",
781 LogicalId: "A",
782 });
783 expect(node?.resource.Namespace).toBeDefined();
784 // persisted bindings surface as noop rows (topology, not changes)
785 expect(node?.bindings).toMatchObject([{ sid: "Env", action: "noop" }]);
786 }).pipe(withCloud(cloud));
787 },
788 );
789});
790
791describe("session events", { tags: ["unit", "local"] }, () => {
792 test.provider(
793 "repair reports progress through the provided session",
794 (stack) => {
795 const cloud = makeTestCloud();
796 const events: ApplyEvent[] = [];
797 const session = {
798 emit: (event: ApplyEvent) =>
799 Effect.sync(() => {
800 events.push(event);
801 }),
802 done: () => Effect.void,
803 };
804 return Effect.gen(function* () {
805 yield* stack.deploy(
806 Effect.gen(function* () {
807 yield* DriftResource("Drifted", { value: "drifted" });
808 yield* DriftResource("Missing", { value: "missing" });
809 yield* Bucket("MyBucket", { name: "test-bucket" });
810 }),
811 );
812
813 cloud.resources.get("Drifted")!.value = "hijacked";
814 cloud.resources.delete("Missing");
815
816 yield* runDrift({ session });
817
818 // drift repair reports the update lifecycle
819 expect(events).toContainEqual({
820 _tag: "apply.resource.status",
821 fqn: "Drifted",
822 id: "Drifted",
823 type: "Test.DriftResource",
824 status: "updating",
825 });
826 expect(events).toContainEqual({
827 _tag: "apply.resource.status",
828 fqn: "Drifted",
829 id: "Drifted",
830 type: "Test.DriftResource",
831 status: "updated",
832 });
833 // recreation reports the create lifecycle
834 expect(events).toContainEqual({
835 _tag: "apply.resource.status",
836 fqn: "Missing",
837 id: "Missing",
838 type: "Test.DriftResource",
839 status: "creating",
840 });
841 expect(events).toContainEqual({
842 _tag: "apply.resource.status",
843 fqn: "Missing",
844 id: "Missing",
845 type: "Test.DriftResource",
846 status: "created",
847 });
848 // skipped resources settle with a terminal status and a reason note
849 expect(events).toContainEqual({
850 _tag: "apply.resource.status",
851 fqn: "MyBucket",
852 id: "MyBucket",
853 type: "Test.Bucket",
854 status: "skipped",
855 });
856 expect(
857 events.some(
858 (e) =>
859 e._tag === "apply.resource.note" &&
860 e.id === "MyBucket" &&
861 e.message.includes("read"),
862 ),
863 ).toBe(true);
864 }).pipe(withCloud(cloud));
865 },
866 );
867});