1/** @effect-diagnostics anyUnknownInErrorContext:off */
2import * as Cause from "effect/Cause";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7import { stripUnowned } from "./AdoptPolicy.ts";
8import {
9 Artifacts,
10 ArtifactStore,
11 createArtifactStore,
12 ensureArtifactStore,
13 makeScopedArtifacts,
14} from "./Artifacts.ts";
15import {
16 noopSession,
17 Progress,
18 type PlanStatusSession,
19 type ScopedPlanStatusSession,
20} from "./Report.ts";
21import { deepEqual } from "./Diff.ts";
22import { InstanceId } from "./InstanceId.ts";
23import type { Apply, Plan } from "./Plan.ts";
24import { findProviderByType, Provider } from "./Provider.ts";
25import { stampedMode } from "./ProviderMode.ts";
26import type { ResourceLike } from "./Resource.ts";
27import {
28 isActionState,
29 State,
30 type CreatedResourceState,
31 type ResourceState,
32 type UpdatedResourceState,
33} from "./State/index.ts";
34import { type ResourceOp, recordResourceOp } from "./Telemetry/Metrics.ts";
35
36/**
37 * The outcome of checking or repairing drift for a single resource.
38 *
39 * - `unchanged` — the observed cloud state matches the persisted attributes.
40 * - `drifted` — (dry-run only) the cloud state diverged from the persisted
41 * attributes; a non-dry-run drift would repair it.
42 * - `missing` — (dry-run only) the resource no longer exists in the cloud;
43 * a non-dry-run drift would recreate it.
44 * - `repaired` — drift was detected and the resource was reconciled back to
45 * its desired (last-deployed) state.
46 * - `recreated` — the resource was missing from the cloud and was reconciled
47 * from scratch, reusing the persisted instance id so
48 * deterministic physical names converge to the same values.
49 * - `skipped` — the resource was not checked; see `reason` (provider has no
50 * `read`, or the persisted status is not stable).
51 */
52export type DriftAction =
53 | "unchanged"
54 | "drifted"
55 | "missing"
56 | "repaired"
57 | "recreated"
58 | "skipped";
59
60export interface DriftResourceResult {
61 fqn: string;
62 logicalId: string;
63 resourceType: string;
64 action: DriftAction;
65 /** Why the resource was skipped (only set when `action === "skipped"`). */
66 reason?: string;
67 /**
68 * The resource's attributes after the drift: the reconciled attributes for
69 * `repaired`/`recreated`, the observed cloud attributes for `drifted`, and
70 * the persisted attributes for `unchanged`. Unset for `missing`/`skipped`.
71 */
72 attr?: any;
73}
74
75export interface DriftResult {
76 resources: Record<string, DriftResourceResult>;
77}
78
79export interface DriftOptions {
80 /** Optional progress session (the CLI passes one; tests usually don't). */
81 session?: PlanStatusSession;
82}
83
84/** A provider lifecycle failure annotated with the resource being checked. */
85export class DriftResourceError extends Data.TaggedError("DriftResourceError")<{
86 readonly message: string;
87 readonly fqn: string;
88 readonly logicalId: string;
89 readonly resourceType: string;
90 readonly operation: ResourceOp;
91 readonly cause: unknown;
92}> {}
93
94/**
95 * Reconcile state drift for every resource persisted under `stack`/`stage`.
96 *
97 * Unlike `deploy` (which converges the cloud to a *new* desired state
98 * computed from the stack program), `drift` converges the cloud back to the
99 * *last-deployed* desired state recorded in the state store. It needs no
100 * stack program — only the state store and the resource providers.
101 *
102 * The algorithm, per resource, is observe → compare → converge:
103 *
104 * 1. **Read** — call `provider.read` with the persisted props/attributes to
105 * observe the live cloud state.
106 * 2. **Compare** — deep-compare the observed attributes against the
107 * persisted attributes. Equal ⇒ `unchanged`.
108 * 3. **Reconcile** — on drift, call `provider.reconcile` with the persisted
109 * props as the desired state (`news`) and the *observed* attributes as
110 * `output`, so the provider diffs against reality rather than a stale
111 * snapshot. When the resource is missing entirely, reconcile runs
112 * greenfield (`olds`/`output` undefined) under the *same* instance id so
113 * deterministic physical names regenerate identically.
114 * 4. **Persist** — write the fresh attributes back to the state store.
115 *
116 * Resources are checked concurrently and independently — persisted props are
117 * fully resolved values, so there are no upstream/downstream data edges to
118 * order by. A failure checking one resource does not interrupt the others;
119 * all failures are aggregated into a single combined cause after every
120 * resource has been attempted.
121 *
122 * Resources that cannot be checked are reported as `skipped` rather than
123 * failing the run: providers without `read` (nothing to observe), and
124 * resources whose persisted status is not stable (`creating`, `updating`,
125 * `replacing`, `replaced`, `deleting`) — those represent an interrupted
126 * deploy and must be recovered by `deploy`, which owns replacement chains
127 * and dependency ordering. Action rows have no cloud state and are ignored.
128 */
129const runDrift = (
130 stack: { name: string; stage: string },
131 options: DriftOptions & { readonly dryRun: boolean },
132): Effect.Effect<DriftResult, any, State> =>
133 Effect.gen(function* () {
134 // Phase markers + per-resource observation progress go through the
135 // ambient Progress reporter (a no-op by default) so renderers can show the
136 // drift check as it runs.
137 const reportPlanned = yield* Progress;
138 yield* reportPlanned({ _tag: "plan.phase", phase: "loading-state" });
139 const state = yield* yield* State;
140 const session = options.session ?? noopSession;
141 const stackName = stack.name;
142 const stage = stack.stage;
143 const dryRun = options.dryRun;
144
145 const driftResource = Effect.fn("drift.resource")(function* (fqn: string) {
146 const persisted = yield* state.get({ stack: stackName, stage, fqn });
147 // Action rows have no cloud state to drift.
148 if (!persisted || isActionState(persisted)) {
149 return undefined;
150 }
151 const old = persisted as ResourceState;
152 const { logicalId, instanceId, resourceType, namespace } = old;
153
154 const result = (
155 partial: Pick<DriftResourceResult, "action" | "reason" | "attr">,
156 ): DriftResourceResult => ({
157 fqn,
158 logicalId,
159 resourceType,
160 ...partial,
161 });
162
163 const scopedSession = {
164 ...session,
165 note: (note, options?) =>
166 session.emit({
167 fqn,
168 id: logicalId,
169 _tag: "apply.resource.note",
170 message: note,
171 kind: options?.kind,
172 }),
173 } satisfies ScopedPlanStatusSession;
174
175 const report = (
176 status: "updating" | "updated" | "creating" | "created" | "skipped",
177 ) =>
178 session.emit({
179 _tag: "apply.resource.status",
180 fqn,
181 id: logicalId,
182 type: resourceType,
183 status,
184 });
185
186 // Surface the skip reason through the session (the TUI renders it as a
187 // note under the row; the logging CLI prints it) and settle the row
188 // with a terminal `skipped` status so it never shows as in-progress.
189 const skip = (reason: string) =>
190 Effect.gen(function* () {
191 yield* scopedSession.note(reason);
192 yield* report("skipped");
193 return result({ action: "skipped", reason });
194 });
195
196 if (old.status !== "created" && old.status !== "updated") {
197 // Anything mid-flight (or with a pending replacement chain) belongs
198 // to `deploy`'s recovery machinery — replacement generations and
199 // dependency ordering are not drift's to drive.
200 return yield* skip(
201 old.status === "replaced" || old.status === "replacing"
202 ? `resource has a pending replacement (status '${old.status}'); run deploy to finish it`
203 : `resource status '${old.status}' is not stable; run deploy to recover`,
204 );
205 }
206
207 // Observe with the provider variant of the mode that created the row —
208 // a local dev worker's state must be read by the local provider.
209 // Legacy unstamped rows infer "local" from a `dev:` identity marker.
210 const provider = yield* findProviderByType(
211 resourceType,
212 stampedMode(old),
213 );
214 if (!provider.read) {
215 return yield* skip(
216 `provider '${resourceType}' does not implement read`,
217 );
218 }
219
220 const commit = (value: Omit<ResourceState, "namespace">) =>
221 state.set({
222 stack: stackName,
223 stage,
224 fqn,
225 value: { ...value, namespace } as ResourceState,
226 });
227
228 // Paired with the resource-planned completion so renderers can show
229 // which rows' cloud reads are actually in flight (the slowest part of
230 // a drift check). Emitted after the skip gates: skipped rows settle
231 // instantly and never read the cloud.
232 yield* reportPlanned({
233 _tag: "plan.resource.started",
234 fqn,
235 logicalId,
236 resourceType,
237 total: fqns.length,
238 });
239 const observed = yield* provider
240 .read({
241 id: logicalId,
242 fqn,
243 instanceId,
244 olds: old.props as never,
245 output: old.attr as never,
246 })
247 .pipe(
248 instrumentLifecycle("read", fqn, resourceType, logicalId, instanceId),
249 );
250
251 // ── missing — recreate under the same instance id ──
252 if (observed === undefined) {
253 if (dryRun) {
254 return result({ action: "missing" });
255 }
256 yield* report("creating");
257 const attr = yield* provider
258 .reconcile({
259 id: logicalId,
260 fqn,
261 instanceId,
262 news: old.props as never,
263 olds: undefined,
264 output: undefined,
265 session: scopedSession,
266 bindings: old.bindings as never,
267 })
268 .pipe(
269 instrumentLifecycle(
270 "create",
271 fqn,
272 resourceType,
273 logicalId,
274 instanceId,
275 ),
276 );
277 yield* commit({
278 status: "created",
279 fqn,
280 logicalId,
281 instanceId,
282 resourceType,
283 props: old.props!,
284 attr,
285 providerVersion: provider.version ?? 0,
286 bindings: old.bindings,
287 downstream: old.downstream,
288 removalPolicy: old.removalPolicy,
289 } satisfies Omit<CreatedResourceState, "namespace">);
290 yield* report("created");
291 return result({ action: "recreated", attr });
292 }
293
294 // `read` may brand the attributes as Unowned when ownership markers
295 // (tags) have drifted out from under us — the brand is a plan-time
296 // routing hint, never persisted, and here the state store already
297 // records the resource as ours. Tag drift then surfaces through the
298 // attribute comparison below and repairs like any other drift.
299 const live = stripUnowned(observed);
300
301 if (deepEqual(live, old.attr)) {
302 return result({ action: "unchanged", attr: old.attr });
303 }
304
305 if (dryRun) {
306 return result({ action: "drifted", attr: live });
307 }
308
309 // ── drifted — converge the cloud back to the last-deployed props ──
310 yield* report("updating");
311 const attr = yield* provider
312 .reconcile({
313 id: logicalId,
314 fqn,
315 instanceId,
316 news: old.props as never,
317 olds: old.props as never,
318 // Hand reconcile the OBSERVED attributes, not the stale persisted
319 // snapshot, so its observed-vs-desired diffing works from reality.
320 output: live as never,
321 session: scopedSession,
322 bindings: old.bindings as never,
323 })
324 .pipe(
325 instrumentLifecycle(
326 "update",
327 fqn,
328 resourceType,
329 logicalId,
330 instanceId,
331 ),
332 );
333 yield* commit({
334 status: "updated",
335 fqn,
336 logicalId,
337 instanceId,
338 resourceType,
339 props: old.props!,
340 attr,
341 providerVersion: provider.version ?? 0,
342 bindings: old.bindings,
343 downstream: old.downstream,
344 removalPolicy: old.removalPolicy,
345 } satisfies Omit<UpdatedResourceState, "namespace">);
346 yield* report("updated");
347 return result({ action: "repaired", attr });
348 });
349
350 const fqns = yield* state.list({ stack: stackName, stage });
351 yield* reportPlanned({ _tag: "plan.phase", phase: "computing-plan" });
352
353 // Per-resource observation progress: each row is reported through the
354 // ambient Progress reporter as its cloud read lands (drift checks are the
355 // slowest planning path). Failed or action rows advance the count
356 // without an event, so progress stays monotonic.
357 const observedCount = yield* Ref.make(0);
358 const resourceObserved = (result: DriftResourceResult | undefined) =>
359 Ref.updateAndGet(observedCount, (count) => count + 1).pipe(
360 Effect.flatMap((completed) =>
361 result === undefined
362 ? Effect.void
363 : reportPlanned({
364 _tag: "plan.resource.completed",
365 fqn: result.fqn,
366 logicalId: result.logicalId,
367 resourceType: result.resourceType,
368 // Drift observation compares attributes, not binding rows.
369 bindings: [],
370 action:
371 result.action === "drifted" || result.action === "repaired"
372 ? "update"
373 : result.action === "missing"
374 ? "create"
375 : "noop",
376 completed,
377 total: fqns.length,
378 }),
379 ),
380 );
381
382 // Drift every resource even if some fail, then surface all failures as
383 // one combined cause (mirrors Apply's failure aggregation).
384 const failures: Cause.Cause<unknown>[] = [];
385 const results = yield* Effect.all(
386 fqns.map((fqn) =>
387 driftResource(fqn).pipe(
388 Effect.catchCause((cause) =>
389 Effect.gen(function* () {
390 failures.push(cause);
391 const persisted = yield* state
392 .get({ stack: stackName, stage, fqn })
393 .pipe(Effect.orElseSucceed(() => undefined));
394 if (persisted && !isActionState(persisted)) {
395 yield* session.emit({
396 _tag: "apply.resource.status",
397 fqn,
398 id: persisted.logicalId,
399 type: persisted.resourceType,
400 status: "fail",
401 });
402 }
403 return undefined;
404 }),
405 ),
406 Effect.tap(resourceObserved),
407 ),
408 ),
409 { concurrency: "unbounded" },
410 );
411
412 yield* session.done(failures.length === 0 ? "success" : "failure");
413
414 if (failures.length > 0) {
415 return yield* Effect.failCause(
416 failures.reduce(Cause.combine) as Cause.Cause<never>,
417 );
418 }
419
420 return {
421 resources: Object.fromEntries(
422 results
423 .filter((r): r is DriftResourceResult => r !== undefined)
424 .map((r) => [r.fqn, r]),
425 ),
426 } satisfies DriftResult;
427 }).pipe(
428 ensureArtifactStore,
429 Effect.withSpan("drift", {
430 attributes: {
431 "alchemy.stack": stack.name,
432 "alchemy.stage": stack.stage,
433 "alchemy.dry_run": !!options.dryRun,
434 },
435 }),
436 );
437
438/** Detect drift without reconciling resources or updating state. */
439export const detect = (stack: { name: string; stage: string }) =>
440 runDrift(stack, { dryRun: true });
441
442/** Reconcile resources back to their last-deployed desired state. */
443export const repair = (
444 stack: { name: string; stage: string },
445 options: DriftOptions = {},
446) => runDrift(stack, { ...options, dryRun: false });
447
448/**
449 * Same shape as Apply's lifecycle instrumentation: scoped artifacts +
450 * instance id, the resource op metrics, and a `provider.<op>` span.
451 */
452const instrumentLifecycle =
453 (
454 op: ResourceOp,
455 fqn: string,
456 resourceType: string,
457 logicalId: string,
458 instanceId: string,
459 ) =>
460 <A, E, R>(
461 effect: Effect.Effect<A, E, R>,
462 ): Effect.Effect<
463 A,
464 E | DriftResourceError,
465 Exclude<R, InstanceId | Artifacts>
466 > =>
467 Effect.serviceOption(ArtifactStore).pipe(
468 Effect.map(Option.getOrElse(createArtifactStore)),
469 Effect.flatMap((store) =>
470 effect.pipe(
471 Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
472 Effect.provideService(InstanceId, instanceId),
473 Effect.catchCause(
474 (cause): Effect.Effect<never, E | DriftResourceError> =>
475 Cause.hasInterruptsOnly(cause)
476 ? Effect.failCause(cause)
477 : Effect.fail(
478 new DriftResourceError({
479 message: `Resource '${fqn}' (${resourceType}) failed during ${op}`,
480 fqn,
481 logicalId,
482 resourceType,
483 operation: op,
484 cause: Cause.squash(cause),
485 }),
486 ),
487 ),
488 ),
489 ),
490 recordResourceOp(resourceType, op),
491 Effect.withSpan(`provider.${op}`, {
492 attributes: {
493 "alchemy.resource.fqn": fqn,
494 "alchemy.resource.type": resourceType,
495 "alchemy.resource.logical_id": logicalId,
496 "alchemy.resource.instance_id": instanceId,
497 "alchemy.resource.op": op,
498 },
499 }),
500 ) as Effect.Effect<
501 A,
502 E | DriftResourceError,
503 Exclude<R, InstanceId | Artifacts>
504 >;
505
506export interface DriftPlan {
507 /** Per-resource detection outcome (a dry-run {@link DriftResult}). */
508 result: DriftResult;
509 /**
510 * The detection outcome projected onto the engine's {@link Plan} shape so
511 * the CLI renders a drift exactly like a deploy plan (ink TUI when
512 * interactive, plain logging otherwise): `drifted` → `update`, `missing` →
513 * `create`, `unchanged`/`skipped` → `noop`.
514 */
515 plan: Plan;
516}
517
518/**
519 * Run the drift-detection pass and project the
520 * outcome onto a {@link Plan} for display/approval. The plan is a read-only
521 * view — {@link repair} re-observes the cloud rather than trusting the
522 * detection snapshot.
523 */
524export const plan = (stack: {
525 name: string;
526 stage: string;
527}): Effect.Effect<DriftPlan, any, State> =>
528 Effect.gen(function* () {
529 const result = yield* detect(stack);
530 const state = yield* yield* State;
531
532 const resources: Plan["resources"] = {};
533 for (const [fqn, r] of Object.entries(result.resources)) {
534 const persisted = yield* state.get({
535 stack: stack.name,
536 stage: stack.stage,
537 fqn,
538 });
539 if (!persisted || isActionState(persisted)) continue;
540 // Repair the row with the provider mode that created it (drift never
541 // switches modes — a local ⇄ live switch is a plan-time replacement).
542 const provider = yield* findProviderByType(
543 persisted.resourceType,
544 stampedMode(persisted),
545 );
546 const action =
547 r.action === "drifted"
548 ? ("update" as const)
549 : r.action === "missing"
550 ? ("create" as const)
551 : ("noop" as const);
552 resources[fqn] = {
553 action,
554 props: persisted.props,
555 drift:
556 r.action === "drifted" || r.action === "missing"
557 ? {
558 expected: persisted.attr,
559 actual: r.attr,
560 missing: r.action === "missing",
561 }
562 : undefined,
563 state: persisted,
564 provider,
565 mode: persisted.providerMode,
566 // Synthetic ResourceLike reconstructed from persisted state, the
567 // same way Plan.make builds its deletion nodes.
568 resource: {
569 Namespace: persisted.namespace,
570 FQN: fqn,
571 LogicalId: persisted.logicalId,
572 Type: persisted.resourceType,
573 Attributes: persisted.attr,
574 Props: persisted.props,
575 Binding: undefined!,
576 Provider: Provider(persisted.resourceType),
577 RemovalPolicy: persisted.removalPolicy,
578 Adopt: undefined,
579 RequiresImplementation: undefined,
580 FormerFqns: undefined,
581 Mode: persisted.providerMode,
582 RuntimeContext: undefined!,
583 Providers: undefined,
584 } as ResourceLike,
585 // Drift repairs from the persisted bindings verbatim — surface them
586 // as noop rows so the renderer shows the binding topology without
587 // implying binding changes.
588 bindings: (persisted.bindings ?? []).map((binding) => ({
589 sid: binding.sid,
590 action: "noop" as const,
591 data: binding.data,
592 })),
593 downstream: persisted.downstream ?? [],
594 } as Apply;
595 }
596
597 return {
598 result,
599 plan: {
600 resources,
601 actions: {},
602 deletions: {},
603 actionDeletions: {},
604 output: undefined,
605 cycleMembers: new Set<string>(),
606 },
607 } satisfies DriftPlan;
608 }).pipe(
609 Effect.withSpan("drift.plan", {
610 attributes: {
611 "alchemy.stack": stack.name,
612 "alchemy.stage": stack.stage,
613 },
614 }),
615 );