1import * as Effect from "effect/Effect";
2import * as EngineDrift from "../../Drift.ts";
3import type { Plan } from "../../Plan.ts";
4import { applySession, Progress, withSpanEvents } from "../Progress.ts";
5import { open, type Session, type StackTarget } from "../Session.ts";
6import * as Stack from "./stack.ts";
7
8export interface DriftedResource {
9 readonly fqn: string;
10 readonly logicalId: string;
11 readonly resourceType: string;
12 readonly status: "in-sync" | "drifted" | "missing";
13 readonly actual?: unknown;
14}
15
16export interface DriftSnapshot {
17 readonly stack: { readonly name: string; readonly stage: string };
18 readonly resources: ReadonlyArray<DriftedResource>;
19 readonly repairPlan: {
20 readonly summary: Stack.PlanSummary;
21 readonly native: Plan;
22 };
23 /** The session drift was inspected under; {@link repair} runs in it. */
24 readonly session: Session;
25}
26
27/** Whether the drift check found anything worth repairing. */
28export const hasDrift = (snapshot: DriftSnapshot): boolean =>
29 snapshot.resources.some(
30 (resource) =>
31 resource.status === "drifted" || resource.status === "missing",
32 );
33
34const status = (action: string) =>
35 action === "unchanged"
36 ? ("in-sync" as const)
37 : action === "missing"
38 ? ("missing" as const)
39 : ("drifted" as const);
40
41/** Compare deployed state against the real cloud and plan the repair. */
42export const inspect = Effect.fn("Alchemist.drift.inspect")(function* (
43 target: StackTarget,
44) {
45 const report = withSpanEvents(yield* Progress);
46 // `open` emits importing-module / resolving-services at the real work
47 // boundaries; hand it the wrapped reporter so they land in traces too.
48 const session = yield* open(target).pipe(
49 Effect.provideService(Progress, report),
50 );
51 const identity = {
52 name: session.stack.name,
53 stage: session.stack.stage,
54 };
55 const { result, plan } = yield* EngineDrift.plan(identity).pipe(
56 // The engine's phase and per-resource observation events flow through
57 // the same wrapped reporter; drift-flavored wording is the renderer's
58 // job (renderPlanning's computingLabel).
59 Effect.provideService(Progress, report),
60 Effect.provide(session.context),
61 );
62 yield* report({ _tag: "plan.phase", phase: "plan-ready" });
63 return {
64 stack: identity,
65 resources: Object.values(result.resources)
66 .filter((resource) => resource.action !== "skipped")
67 .map((resource) => ({
68 fqn: resource.fqn,
69 logicalId: resource.logicalId,
70 resourceType: resource.resourceType,
71 status: status(resource.action),
72 actual: resource.attr,
73 })),
74 repairPlan: { summary: Stack.summarize(plan), native: plan },
75 session,
76 } satisfies DriftSnapshot;
77});
78
79/**
80 * Converge state back to the cloud's actual shape. Engine apply events are
81 * reported through {@link Progress}.
82 */
83export const repair = Effect.fn("Alchemist.drift.repair")(function* (
84 snapshot: DriftSnapshot,
85) {
86 const report = withSpanEvents(yield* Progress);
87 return yield* Effect.provide(
88 EngineDrift.repair(
89 { name: snapshot.stack.name, stage: snapshot.stack.stage },
90 { session: applySession(report) },
91 ),
92 snapshot.session.context,
93 );
94});