@homeflare/alchemy 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/caddy/index.js.map +2 -2
  2. package/dist/launchd/index.js.map +2 -2
  3. package/dist/openbao/index.js +10 -8
  4. package/dist/openbao/index.js.map +4 -4
  5. package/dist/ownership/adopting.d.ts +34 -0
  6. package/dist/ownership/adopting.d.ts.map +1 -0
  7. package/dist/ownership/probe.d.ts +15 -5
  8. package/dist/ownership/probe.d.ts.map +1 -1
  9. package/dist/ownership/rows.d.ts +12 -0
  10. package/dist/ownership/rows.d.ts.map +1 -1
  11. package/dist/ownership/store-fixture.d.ts +10 -0
  12. package/dist/ownership/store-fixture.d.ts.map +1 -0
  13. package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
  14. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  15. package/dist/proxmox/index.js +147 -113
  16. package/dist/proxmox/index.js.map +8 -6
  17. package/dist/proxmox/lxc-adoption.d.ts +32 -0
  18. package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
  19. package/dist/proxmox/lxc-harness.d.ts +12 -0
  20. package/dist/proxmox/lxc-harness.d.ts.map +1 -1
  21. package/dist/proxmox/lxc.d.ts +5 -5
  22. package/dist/proxmox/lxc.d.ts.map +1 -1
  23. package/dist/verify/cli.js +79 -29
  24. package/dist/verify/cli.js.map +8 -6
  25. package/dist/verify/fake-engine.d.ts +9 -1
  26. package/dist/verify/fake-engine.d.ts.map +1 -1
  27. package/dist/verify/index.d.ts +1 -1
  28. package/dist/verify/index.d.ts.map +1 -1
  29. package/dist/verify/index.js +67 -17
  30. package/dist/verify/index.js.map +8 -6
  31. package/dist/verify/plan-scope.d.ts +14 -0
  32. package/dist/verify/plan-scope.d.ts.map +1 -0
  33. package/dist/verify/recheck.d.ts +42 -0
  34. package/dist/verify/recheck.d.ts.map +1 -0
  35. package/dist/verify/rows.d.ts +13 -3
  36. package/dist/verify/rows.d.ts.map +1 -1
  37. package/dist/verify/spy.d.ts +12 -0
  38. package/dist/verify/spy.d.ts.map +1 -1
  39. package/dist/verify/verify.d.ts.map +1 -1
  40. package/docs/adopt-verify.md +13 -2
  41. package/docs/adopted-deploys.md +4 -2
  42. package/docs/ownership.md +1 -1
  43. package/docs/proxmox-lxc-adopt.md +83 -0
  44. package/docs/proxmox-lxc.md +7 -45
  45. package/docs/proxmox.md +2 -0
  46. package/package.json +1 -1
  47. package/src/ownership/adopting.test.ts +63 -0
  48. package/src/ownership/adopting.ts +46 -0
  49. package/src/ownership/ownership.test.ts +1 -34
  50. package/src/ownership/probe.ts +27 -14
  51. package/src/ownership/rows.ts +8 -2
  52. package/src/ownership/store-fixture.ts +50 -0
  53. package/src/proxmox/ceph-pool-adopt.test.ts +32 -0
  54. package/src/proxmox/fake-pve-lxc.ts +13 -1
  55. package/src/proxmox/lxc-adopt.test.ts +13 -20
  56. package/src/proxmox/lxc-adoption.ts +50 -0
  57. package/src/proxmox/lxc-guards.test.ts +6 -13
  58. package/src/proxmox/lxc-harness.ts +25 -2
  59. package/src/proxmox/lxc-ownership.test.ts +19 -14
  60. package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
  61. package/src/proxmox/lxc.ts +16 -12
  62. package/src/verify/fake-engine.ts +24 -0
  63. package/src/verify/index.ts +1 -0
  64. package/src/verify/plan-scope.ts +34 -0
  65. package/src/verify/recheck.test.ts +56 -0
  66. package/src/verify/recheck.ts +71 -0
  67. package/src/verify/rows.ts +46 -5
  68. package/src/verify/spy.ts +14 -3
  69. package/src/verify/verify.test.ts +22 -0
  70. package/src/verify/verify.ts +38 -25
@@ -7,12 +7,15 @@
7
7
  * this file only decides.
8
8
  */
9
9
  import { deepEqual, isResolved } from 'alchemy/Diff';
10
- import type { DiffAnswer, Observations, ReadAnswer } from './spy.ts';
10
+ import type { DiffAnswer, Observations, ReadAnswer, RecheckAnswer } from './spy.ts';
11
11
 
12
12
  export interface AdoptRow {
13
13
  readonly fqn: string;
14
14
  readonly type: string;
15
- /** Whether the state store holds a row for this FQN. An adoption is a row without one. */
15
+ /**
16
+ * Whether the row plans from a row the state store holds for it (verify.ts `withState`). An
17
+ * adoption is a row without one.
18
+ */
16
19
  readonly stateRow: boolean;
17
20
  /** The engine's action — what `alchemy plan` prints (`adopted`, `create`, `noop`, …). */
18
21
  readonly planned: string;
@@ -23,9 +26,16 @@ export interface AdoptRow {
23
26
  /**
24
27
  * Declared fields whose live value differs, by NAME only — values can be secrets. Compared on
25
28
  * same-named keys of the declaration and the read's attributes, so a family whose attributes
26
- * rename a field cannot show it here; the diff answer, not this list, is the verdict.
29
+ * rename a field cannot show it here. The diff answer is the verdict; a name here beside an
30
+ * adopted `noop` only makes recheck.ts ask that diff again.
27
31
  */
28
32
  readonly changed: readonly string[];
33
+ /**
34
+ * What the provider's `diff` answered when asked again with the live values of `changed` as its
35
+ * recorded props (recheck.ts). Absent when it was not asked: only an adopted `noop` with
36
+ * something in `changed` is.
37
+ */
38
+ readonly recheck?: RecheckAnswer;
29
39
  /** Bindings the engine will create, update or delete — reconcile applies them even on `noop`. */
30
40
  readonly bindings: number;
31
41
  readonly ok: boolean;
@@ -77,9 +87,38 @@ const isNoop = (planned: string, diff: DiffAnswer, stateRow: boolean) =>
77
87
  STILL_LIVE.has(planned) &&
78
88
  (diff === 'noop' || (diff === 'none' && stateRow && planned === 'noop'));
79
89
 
90
+ /**
91
+ * ⛔ AND A `noop` MUST SURVIVE BEING ASKED WITH THE LIVE VALUES. A diff that compares recorded props
92
+ * answers an adopted row `noop` whatever the cloud holds; recheck.ts asks it again, and only a
93
+ * second `noop` (or no need to ask) proves anything.
94
+ */
95
+ const proven = (recheck: RecheckAnswer | undefined) => recheck === undefined || recheck === 'noop';
96
+
97
+ /** Notes on a row that passes: nothing to say is the common case. */
98
+ const notes = (row: Omit<AdoptRow, 'ok' | 'why'>): string =>
99
+ [
100
+ row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '',
101
+ row.recheck === 'noop'
102
+ ? `${row.changed.join(', ')} differ, but the diff says noop even given the live values: ` +
103
+ 'the family does not manage them, and the deploy leaves them alone'
104
+ : '',
105
+ ]
106
+ .filter((note) => note !== '')
107
+ .join('; ');
108
+
80
109
  const reason = (row: Omit<AdoptRow, 'ok' | 'why'>, ok: boolean): string => {
81
- if (ok) return row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '';
110
+ if (ok) return notes(row);
82
111
  if (isNoop(row.planned, row.diff, row.stateRow)) {
112
+ const live = `the live ${row.changed.join(', ')}`;
113
+ if (row.recheck === 'failed') {
114
+ return `asked again with ${live} as recorded props, the provider's diff failed: not proven`;
115
+ }
116
+ if (!proven(row.recheck)) {
117
+ return (
118
+ `the provider's diff compares recorded props, not the live object: given ${live} it ` +
119
+ `says ${String(row.recheck)}, and the deploy's forced reconcile writes`
120
+ );
121
+ }
83
122
  return `${String(row.bindings)} binding(s) change: the deploy reconciles them though the diff is noop`;
84
123
  }
85
124
  if (row.planned === 'delete') return 'no longer declared: the deploy deletes it';
@@ -107,6 +146,7 @@ export const rowFor = (
107
146
  seen: Observations,
108
147
  ): AdoptRow => {
109
148
  const observed = seen.get(fqn);
149
+ const recheck = observed?.recheck;
110
150
  const base = {
111
151
  bindings: (node.bindings ?? []).filter((binding) => binding.action !== 'noop').length,
112
152
  changed: changedFields(observed?.diff?.news, observed?.read?.attributes),
@@ -114,6 +154,7 @@ export const rowFor = (
114
154
  fqn,
115
155
  planned: node.action,
116
156
  read: observed?.read?.answer ?? 'not-read',
157
+ ...(recheck === undefined ? {} : { recheck }),
117
158
  stateRow,
118
159
  type: node.resource.Type,
119
160
  } satisfies Omit<AdoptRow, 'ok' | 'why'>;
@@ -122,7 +163,7 @@ export const rowFor = (
122
163
  * non-noop binding to `reconcile`, which an adopted row reaches anyway — a Worker adopted with
123
164
  * a new binding writes although its own diff said nothing changed.
124
165
  */
125
- const ok = isNoop(base.planned, base.diff, stateRow) && base.bindings === 0;
166
+ const ok = isNoop(base.planned, base.diff, stateRow) && proven(recheck) && base.bindings === 0;
126
167
  return { ...base, ok, why: reason(base, ok) };
127
168
  };
128
169
 
package/src/verify/spy.ts CHANGED
@@ -31,11 +31,22 @@ import * as Effect from 'effect/Effect';
31
31
 
32
32
  export type ReadAnswer = 'found' | 'unowned' | 'absent' | 'failed' | 'not-read';
33
33
  export type DiffAnswer = 'noop' | 'update' | 'replace' | 'none' | 'not-run';
34
+ /** What the diff answered when asked again with the live values as its recorded props (recheck.ts). */
35
+ export type RecheckAnswer = DiffAnswer | 'failed';
36
+
37
+ /** Exactly what the planner handed a provider's `diff`. */
38
+ export type DiffInput = Parameters<NonNullable<ProviderService['diff']>>[0];
34
39
 
35
40
  /** What one FQN's provider answered during the plan — the LAST answer of each kind. */
36
41
  export interface Seen {
37
42
  read?: { readonly answer: ReadAnswer; readonly attributes: unknown };
38
- diff?: { readonly answer: DiffAnswer; readonly news: unknown };
43
+ /**
44
+ * ★ `input` IS THE WHOLE CALL, NOT ONLY `news`, so recheck.ts can ask the same question again
45
+ * with one field changed. In memory only; the report carries field names, never values. An
46
+ * observation built by hand without it (rows.ts's tests) is simply never rechecked.
47
+ */
48
+ diff?: { readonly answer: DiffAnswer; readonly news: unknown; readonly input?: DiffInput };
49
+ recheck?: RecheckAnswer;
39
50
  }
40
51
 
41
52
  /** Keyed by FQN. Filled while the plan runs; read once it is done. */
@@ -63,7 +74,7 @@ const refuse = (type: string, operation: string) => () =>
63
74
  const readAnswer = (attributes: unknown): ReadAnswer =>
64
75
  attributes === undefined ? 'absent' : Unowned.is(attributes) ? 'unowned' : 'found';
65
76
 
66
- const diffAnswer = (diff: unknown): DiffAnswer => {
77
+ export const diffAnswer = (diff: unknown): DiffAnswer => {
67
78
  const action = (diff as { action?: unknown } | undefined)?.action;
68
79
  return action === 'noop' || action === 'update' || action === 'replace' ? action : 'none';
69
80
  };
@@ -82,7 +93,7 @@ export const spyService = (type: string, service: ProviderService, seen: Observa
82
93
  (diff === undefined ? Effect.succeed(undefined) : diff(input)).pipe(
83
94
  Effect.tap((answer) =>
84
95
  Effect.sync(() => {
85
- seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), news: input.news };
96
+ seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), input, news: input.news };
86
97
  }),
87
98
  ),
88
99
  ),
@@ -8,6 +8,7 @@
8
8
  * this tool can be re-thought rather than silently duplicated.
9
9
  */
10
10
  import { describe, expect, test } from 'bun:test';
11
+ import { renamedFrom } from 'alchemy';
11
12
  import * as Effect from 'effect/Effect';
12
13
  import {
13
14
  Blind,
@@ -112,6 +113,27 @@ describe('verifySession', () => {
112
113
  expect(writesOf(cloud)).toEqual([]);
113
114
  });
114
115
 
116
+ /**
117
+ * ⚠️ A RENAME MAY HAND ITS OLD ID TO A NEW RESOURCE IN THE SAME DEPLOY (alchemy Rename.ts). The
118
+ * store still holds the renamer's row at that id, so judging state by FQN alone hid the new
119
+ * resource's `create` from the default report. Both rows are without state of their own.
120
+ */
121
+ test('a rename that hands its old id to a new resource shows both, the create included', async () => {
122
+ const cloud = cloudOf();
123
+ const engine = fakeEngine(cloud);
124
+ await engine.deploy(one('old', 'x'));
125
+ const body = Effect.all([
126
+ Thing('moved', { comment: 'x', name: 'old' }).pipe(renamedFrom('old')),
127
+ Thing('old', { name: 'fresh' }),
128
+ ]);
129
+ const report = await engine.verify(body);
130
+ const byFqn = Object.fromEntries(report.rows.map((row) => [row.fqn, row]));
131
+ expect(Object.keys(byFqn).sort()).toEqual(['moved', 'old']);
132
+ expect(byFqn['old']).toMatchObject({ ok: false, planned: 'create', stateRow: false });
133
+ expect(byFqn['moved']).toMatchObject({ ok: false, stateRow: false });
134
+ expect(writesOf(cloud)).toEqual(['reconcile old']);
135
+ });
136
+
115
137
  test('with all, a row no longer declared is a pending delete', async () => {
116
138
  const cloud = cloudOf();
117
139
  const engine = fakeEngine(cloud);
@@ -11,25 +11,20 @@
11
11
  *
12
12
  * ⛔ READ-ONLY BY CONSTRUCTION. The planner calls `read` and `diff`; spy.ts replaces `reconcile`,
13
13
  * `delete` and `precreate` with refusals; nothing here calls `apply`. `--all` adds one `read` per
14
- * row with state — still a read by Alchemy's provider contract.
14
+ * row with state, and recheck.ts one more `diff` for an adopted `noop` whose read differs from
15
+ * the declaration — both still reads by Alchemy's provider contract.
15
16
  * ⚠️ READS ARE NOT FREE FOR EVERY FAMILY. A kit PVE/PBS read mints a lease through OpenBao, which
16
17
  * creates a short-lived API token on the cluster, exactly as `alchemy plan` does.
17
18
  */
18
19
  import * as Alchemist from 'alchemy/Alchemist';
19
- import {
20
- ArtifactStore,
21
- Artifacts,
22
- createArtifactStore,
23
- makeScopedArtifacts,
24
- } from 'alchemy/Artifacts';
25
- import { InstanceId } from 'alchemy/InstanceId';
26
20
  import * as Plan from 'alchemy/Plan';
27
21
  import type { ProviderService } from 'alchemy/Provider';
28
22
  import type { CompiledStack } from 'alchemy/Stack';
29
23
  import { State, isActionState } from 'alchemy/State';
30
24
  import type * as Context from 'effect/Context';
31
25
  import * as Effect from 'effect/Effect';
32
- import * as Option from 'effect/Option';
26
+ import { inPlanScope } from './plan-scope.ts';
27
+ import { recheck } from './recheck.ts';
33
28
  import { type AdoptRow, type PlanView, rowsOf } from './rows.ts';
34
29
  import { type Observations, spyContext } from './spy.ts';
35
30
 
@@ -52,8 +47,9 @@ export interface VerifySession {
52
47
  readonly context: Context.Context<unknown>;
53
48
  }
54
49
 
55
- /** The plan-node fields `--all` needs to read a row that has state. */
50
+ /** The plan-node fields `--all` needs to read a row that has state, and the recheck to replay a diff. */
56
51
  type StatefulNode = {
52
+ readonly action: string;
57
53
  readonly provider?: ProviderService;
58
54
  readonly resource: { readonly LogicalId: string };
59
55
  readonly state?: {
@@ -63,37 +59,49 @@ type StatefulNode = {
63
59
  };
64
60
  };
65
61
 
66
- /**
67
- * ⚠️ THE SAME SCOPE Plan.ts GIVES ITS OWN READS (`providePlanScope`, not exported): a scoped
68
- * `Artifacts` bag and the row's `InstanceId`. A kit family that consults either — the ownership
69
- * helpers do — sees what it would see under `alchemy plan`.
70
- */
62
+ /** ⚠️ In the scope Plan.ts gives its own reads (plan-scope.ts). */
71
63
  const readWithState = (fqn: string, node: StatefulNode) =>
72
64
  Effect.gen(function* () {
73
65
  const read = node.provider?.read;
74
66
  if (read === undefined || node.state === undefined) return;
75
- const store = Option.getOrElse(yield* Effect.serviceOption(ArtifactStore), createArtifactStore);
76
67
  yield* read({
77
68
  fqn,
78
69
  id: node.resource.LogicalId,
79
70
  instanceId: node.state.instanceId,
80
71
  olds: node.state.props,
81
72
  output: node.state.attr,
82
- }).pipe(
83
- Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
84
- Effect.provideService(InstanceId, node.state.instanceId),
85
- );
73
+ }).pipe(inPlanScope(fqn, node.state.instanceId));
86
74
  });
87
75
 
88
- /** Which of `fqns` the stage's state store holds a resource row for. */
89
- const withState = (stack: CompiledStack<unknown, unknown>, fqns: readonly string[]) =>
76
+ /**
77
+ * Which planned rows plan FROM a resource row the stage's state store holds for them.
78
+ *
79
+ * ⚠️ THE ROW MUST BE THE ONE THE NODE PLANS FROM, NOT MERELY A ROW AT THE SAME FQN. Alchemy lets a
80
+ * rename hand its old id to a new resource in the same deploy (Rename.ts: `Bucket("Assets")
81
+ * .pipe(renamedFrom("Bucket"))` beside a fresh `Bucket("Bucket")`). The store still holds the
82
+ * renamer's row at `Bucket`, so a check by FQN alone called the reuser stateful and the default
83
+ * report hid its `create`. Matching the node's `state.instanceId` also sees the renamer, whose
84
+ * row still sits at its FORMER FQN, as a row without state.
85
+ */
86
+ const withState = (
87
+ stack: CompiledStack<unknown, unknown>,
88
+ nodes: Readonly<Record<string, StatefulNode>>,
89
+ ) =>
90
90
  Effect.gen(function* () {
91
91
  const store = yield* yield* State;
92
92
  const found = yield* Effect.all(
93
- fqns.map((fqn) =>
93
+ Object.entries(nodes).map(([fqn, node]) =>
94
94
  store
95
95
  .get({ fqn, stack: stack.name, stage: stack.stage })
96
- .pipe(Effect.map((row) => (row === undefined || isActionState(row) ? [] : [fqn]))),
96
+ .pipe(
97
+ Effect.map((row) =>
98
+ row === undefined ||
99
+ isActionState(row) ||
100
+ (row as { readonly instanceId?: unknown }).instanceId !== node.state?.instanceId
101
+ ? []
102
+ : [fqn],
103
+ ),
104
+ ),
97
105
  ),
98
106
  { concurrency: 8 },
99
107
  );
@@ -118,7 +126,12 @@ export const verifySession = (
118
126
  >;
119
127
  return Effect.gen(function* () {
120
128
  const planned = yield* plan;
121
- const stateRows = yield* withState(session.stack, Object.keys(planned.resources));
129
+ const stateRows = yield* withState(session.stack, planned.resources);
130
+ // ★ Before the rows are judged: an adopted `noop` beside a differing read is asked again.
131
+ yield* Effect.all(
132
+ Object.entries(planned.resources).map(([fqn, node]) => recheck(fqn, node, seen)),
133
+ { concurrency: 8 },
134
+ );
122
135
  if (all) {
123
136
  const stateful = Object.entries(planned.resources).filter(([fqn]) => stateRows.has(fqn));
124
137
  yield* Effect.all(