@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
package/docs/ownership.md CHANGED
@@ -108,6 +108,6 @@ a declaration, so Apply leaves its object in place with a note instead of deleti
108
108
  - ⚠️ **`CaddyConfig` keeps its own probe.** A running config identical to the declaration reads as
109
109
  ours, because adopting it changes nothing Caddy serves ([caddy.md](./caddy.md#adoption)).
110
110
  `ProxmoxLxc` does not share that exception: its delete removes a guest and its volumes
111
- ([proxmox-lxc.md](./proxmox-lxc.md#adopting-what-the-plan-says)).
111
+ ([proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md)).
112
112
  - ⚠️ **The other `@homeflare/alchemy/proxmox` resources do not follow this rule yet.** Those built
113
113
  on `pveHandlers` (`src/proxmox/resource.ts`) still read a live object with no state as ours.
@@ -0,0 +1,83 @@
1
+ # Proxmox.Lxc — adopting a live guest
2
+
3
+ The adoption half of [proxmox-lxc.md](./proxmox-lxc.md). Two rules, both decided 2026-09-21:
4
+
5
+ 1. ⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, not even one that matches the
6
+ declaration** — the kit-wide rule in [ownership.md](./ownership.md). Without either, the plan
7
+ fails with "Cannot adopt" and nothing is written. Matching is not proof the guest is yours: a
8
+ pasted config or a mistyped vmid reads the same, and once state claims a guest,
9
+ `RemovalPolicy.destroy()` deletes it and its volumes. `.pipe(adopt(false))` stays refused
10
+ under `--adopt`.
11
+ 2. ⛔ **An adoption never changes a guest.** It is taken over exactly as it runs, or not at all.
12
+ With adoption on, a declaration that differs from the live config in any key fails the plan:
13
+
14
+ ```
15
+ CT 100 on pve1: adopting it would change memory, net1. An adoption never changes a guest, so
16
+ nothing is written. Declare what is live for these keys (the plan names keys, never values) and
17
+ plan again; change them after the adoption, as an update.
18
+ ```
19
+
20
+ - **Keys are named, values never.** A live config can carry anything (`description` is where
21
+ people paste credentials), and plan output lands in CI logs.
22
+ - **The fix is the declaration.** Copy the live value for each named key and plan again. A plan
23
+ that says `adopted` and fails nothing is the zero-change check: the deploy writes nothing (two
24
+ GETs), and the next plan is `noop`.
25
+ - **To change the guest, adopt it first.** Once state holds it, a changed declaration is an
26
+ ordinary `update`, and its plan warns with each key the deploy writes:
27
+
28
+ ```
29
+ Proxmox.Lxc pve1/100: live config differs from the declaration in memory -- a deploy writes these.
30
+ ```
31
+
32
+ - **The deploy asks again.** A key edited by hand between the plan and the deploy is refused at
33
+ apply, with nothing written. The adoption's state row stays, and every plan refuses the same way
34
+ until the declaration says what is live. Then it adopts with no write.
35
+ - **It comes before the other refusals.** A root@pam-only key, a smaller disk or an unprivileged
36
+ flip would print a declared value and a `pct set` to run. For an adoption the fix runs the other
37
+ way, so the adoption refusal is the one you see.
38
+
39
+ ★ **Why it is strict.** Alchemy 2.0.0-beta.79 plans a cold adoption as `adopted` whatever it would
40
+ write, and prints no property diff for it. Until this rule, `diff` only logged the keys, and
41
+ `deploy --adopt --yes` then wrote them: a `net0` pasted without the live `tag=` took the guest off
42
+ its VLAN on the deploy that was meant to change nothing.
43
+
44
+ ⚠️ **`alchemy plan` has no `--adopt` flag** (2.0.0-beta.79: only `deploy` declares it). Without
45
+ adoption on, the plan stops at "Cannot adopt" before the adoption check runs. To read the
46
+ adoption's plan, run `alchemy deploy --adopt --dry-run`, or declare `.pipe(adopt(true))` and run
47
+ `alchemy plan`.
48
+
49
+ ## What counts as an adoption
50
+
51
+ State says, at plan and at apply alike (`src/ownership/adopting.ts`):
52
+
53
+ | the guest reached state… | adoption? |
54
+ | ------------------------------------------------------------------------------- | --------- |
55
+ | through the adoption probe: no state for it, found live | yes |
56
+ | on the deploy after that probe, until the adoption commits | yes |
57
+ | through an interrupted create the plan could not prove its own, under `--adopt` | yes |
58
+ | through an interrupted create whose guest still matches what it wrote | no |
59
+ | through this resource's own create, or an adoption that has committed | no |
60
+
61
+ An interrupted create proven its own is not an adoption. If its declaration changed meanwhile, the
62
+ deploy writes the change.
63
+
64
+ ## Creates that find a guest
65
+
66
+ A deploy that planned a **create** never takes over a guest it then finds at that vmid (the plan
67
+ skips its adoption read while a prop is an unresolved Output, and a guest can appear after the
68
+ plan). Without adoption on, the deploy fails and forgets its `creating` row, so the next plan asks.
69
+ With it on, a matching guest is recorded with no write; any other fails until a plan can show it.
70
+
71
+ A create interrupted after its POST resumes on the next deploy **without** `--adopt` when the guest
72
+ still matches what it declared. If someone changed it meanwhile, the plan says "Cannot resume
73
+ creating". `--adopt` then adopts it as it now runs: declare what is live first.
74
+
75
+ A guest the cluster no longer lists at all, with state still held, plans `update` and warns that
76
+ the deploy **creates it again** from `ostemplate`, with new volumes. The plan fails instead when
77
+ there is no `ostemplate`, or when `rootfs`/`mpN` are declared by live volume id (as a paste is):
78
+ a create never reuses a volume.
79
+
80
+ ## Leases
81
+
82
+ `GET …/config` needs `VM.Audit`, which the `read` lease has. A config PUT uses the cached
83
+ `provision` lease. A create, resize or delete mints a fresh one and polls its task with it.
@@ -90,7 +90,8 @@ Every prop is the key and spelling PVE uses in `/nodes/{node}/lxc/{vmid}/config`
90
90
 
91
91
  The `digest` makes PVE refuse the write if the config changed since the deploy read it. ⚠️ That
92
92
  read is the deploy's, not the plan's: a declared key edited by hand between `plan` and `deploy`
93
- is written back to the declaration, even after a plan that warned of nothing.
93
+ is written back to the declaration, even after a plan that warned of nothing. (An adoption
94
+ refuses it instead.)
94
95
 
95
96
  On a running guest, PVE may keep a change as pending until the next restart. A read returns the
96
97
  pending value, so the plan still converges.
@@ -141,51 +142,12 @@ guest that needs `/dev/net/tun` (a Mesh door) or `/dev/dri` is created in two st
141
142
  - **Pool membership, HA, firewall rules, snapshots, replication** are not declared here. Use their
142
143
  own resources, or manage them by hand.
143
144
 
144
- ## Adopting: what the plan says
145
+ ## Adopting
145
146
 
146
- ⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, not even one that matches the
147
- declaration** — the kit-wide rule in [ownership.md](./ownership.md). Without either, the plan fails
148
- with "Cannot adopt" and nothing is written. Matching is not proof the guest is yours: a pasted
149
- config or a mistyped vmid reads the same, and once state claims a guest, `RemovalPolicy.destroy()`
150
- deletes it and its volumes. `.pipe(adopt(false))` stays refused under `--adopt`.
151
-
152
- With adoption on, a cold adoption plans **`adopted`** even when the deploy would write. Alchemy
153
- 2.0.0-beta.79 forces an update after it adopts, and prints no property diff for that update. So
154
- `diff` logs a warning naming each key a deploy would write:
155
-
156
- ```
157
- Proxmox.Lxc pve1/100: live config differs from the declaration in memory, net1 -- a deploy writes these.
158
- ```
159
-
160
- - **No warning** means the declaration is the live config. The deploy writes nothing (two GETs),
161
- and the next plan is `noop`. ★ That is the zero-change adoption check: read the plan first.
162
- - **A warning** means fix the declaration, or accept the named writes. Keys are named, values are
163
- not.
164
-
165
- ⛔ **A warning does not stop the deploy.** `deploy --adopt --yes` writes each named key onto the
166
- live guest. A `net0` declared without the live `tag=` is written without it, and the guest leaves
167
- its VLAN. Only the plan you read makes an adoption zero-change.
168
-
169
- ⚠️ **`alchemy plan` has no `--adopt` flag** (2.0.0-beta.79: only `deploy` declares it). Without
170
- adoption on, the plan stops at "Cannot adopt" before `diff` can warn. To read the adoption's plan,
171
- run `alchemy deploy --adopt --dry-run`, or declare `.pipe(adopt(true))` and run `alchemy plan`.
172
-
173
- A deploy that planned a **create** never takes over a guest it then finds at that vmid (the plan
174
- skips its adoption read while a prop is an unresolved Output, and a guest can appear after the
175
- plan). Without adoption on, the deploy fails and forgets its `creating` row, so the next plan asks.
176
- With it on, a matching guest is recorded with no write; any other fails until a plan can show it.
177
-
178
- A create interrupted after its POST resumes on the next deploy **without** `--adopt` when the guest
179
- still matches what it declared. If someone changed it meanwhile, the plan says "Cannot resume
180
- creating", and `--adopt` resumes it (the warning names the keys it writes back).
181
-
182
- A guest the cluster no longer lists at all, with state still held, plans `update` and warns that
183
- the deploy **creates it again** from `ostemplate`, with new volumes. The plan fails instead when
184
- there is no `ostemplate`, or when `rootfs`/`mpN` are declared by live volume id (as a paste is):
185
- a create never reuses a volume.
186
-
187
- `GET …/config` needs `VM.Audit`, which the `read` lease has. A config PUT uses the cached
188
- `provision` lease. A create, resize or delete mints a fresh one and polls its task with it.
147
+ ⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, and an adoption never changes
148
+ a guest.** It is taken over exactly as it runs, or the plan fails naming each key that differs.
149
+ What the plan says, what counts as an adoption, and the leases each step uses:
150
+ [proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md).
189
151
 
190
152
  ## Removing
191
153
 
package/docs/proxmox.md CHANGED
@@ -8,5 +8,7 @@ or creates one from a template.
8
8
  flip or another template fails the plan. Destroy retains unless `RemovalPolicy.destroy()`.
9
9
  - ⛔ **It adopts nothing without `--adopt` or `adopt(true)`**, a matching guest included
10
10
  ([ownership.md](./ownership.md)): under `destroy`, a claimed guest's disks go with it.
11
+ - ⛔ **An adoption never changes a guest.** Any key that differs from the live config fails the
12
+ plan, named without its value ([proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md)).
11
13
  - ⛔ **root@pam-only keys** (`devN`, bind mounts, features beyond `nesting`) are refused at plan,
12
14
  with the `pct set` to run instead. Guide: [proxmox-lxc.md](./proxmox-lxc.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `adopting` answer by answer, against a store fixture: which rows make a call with attributes an
3
+ * adoption. `Proxmox.Lxc` pins the same answers end to end through the engine
4
+ * (proxmox/lxc-strict-adopt.test.ts, proxmox/lxc-ownership.test.ts).
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import type { ResourceState } from 'alchemy/State/ResourceState';
8
+ import * as Effect from 'effect/Effect';
9
+ import { adopting } from './adopting.ts';
10
+ import { row, withStore } from './store-fixture.ts';
11
+
12
+ const matches = () => Effect.succeed(true);
13
+ const differs = () => Effect.succeed(false);
14
+ const held = { name: 'a' };
15
+ const owner = (instanceId: string, output: unknown = held) => ({ fqn: 'A', instanceId, output });
16
+ const creating = { fqn: 'A', instanceId: 'probe', output: undefined };
17
+
18
+ const recorded = (status: string, extra: Record<string, unknown> = {}): ResourceState =>
19
+ ({ ...row('A', 'mine'), attr: held, status, ...extra }) as unknown as ResourceState;
20
+
21
+ describe('adopting', () => {
22
+ test("the probe's instance, which no row holds, is an adoption", async () => {
23
+ expect(await withStore({ A: recorded('created') }, adopting(owner('probe'), matches))).toBe(
24
+ true,
25
+ );
26
+ });
27
+
28
+ test("Apply's adoption row, and an interrupted one, stay adoptions until they commit", async () => {
29
+ const rows = { A: recorded('updating', { adopting: true }) };
30
+ expect(await withStore(rows, adopting(owner('mine'), matches))).toBe(true);
31
+ });
32
+
33
+ test('a row with attributes and no flag is state vouching for the object', async () => {
34
+ for (const status of ['created', 'updated', 'updating']) {
35
+ expect(await withStore({ A: recorded(status) }, adopting(owner('mine'), differs))).toBe(
36
+ false,
37
+ );
38
+ }
39
+ });
40
+
41
+ test('a recovered create is ours only when its whole row matches live', async () => {
42
+ const rows = { A: row('A', 'mine') };
43
+ expect(await withStore(rows, adopting(owner('mine'), matches))).toBe(false);
44
+ expect(await withStore(rows, adopting(owner('mine'), differs))).toBe(true);
45
+ const holed = { A: row('A', 'mine', undefined, {}) };
46
+ expect(await withStore(holed, adopting(owner('mine'), matches))).toBe(true);
47
+ });
48
+
49
+ test('`settled` is asked with the props of the row that recorded the create', async () => {
50
+ const seen: unknown[] = [];
51
+ const spy = (props: unknown) => Effect.sync(() => (seen.push(props), true));
52
+ await withStore({ A: row('A', 'mine') }, adopting(owner('mine'), spy));
53
+ expect(seen).toEqual([{ name: 'a' }]);
54
+ });
55
+
56
+ test('a create (no attributes held) is never one; reconcile has its own rule', async () => {
57
+ expect(await withStore({}, adopting(creating, differs))).toBe(false);
58
+ });
59
+
60
+ test('outside a stack every call with attributes is an adoption — the strict answer', async () => {
61
+ expect(await Effect.runPromise(adopting(owner('mine'), matches))).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Whether a `diff` or `reconcile` that holds attributes is looking at an ADOPTION — a live object
3
+ * that reached this stack's state by being FOUND, not by this resource's own create — so a family
4
+ * whose adoption must never change what it takes over (`Proxmox.Lxc`) can refuse any difference
5
+ * instead of writing it. adopt.ts and probe.ts decide WHETHER an object may be claimed; this says,
6
+ * once it is claimed, whether the claim is an adoption.
7
+ *
8
+ * ★ STATE ALREADY SAYS WHICH, AT BOTH HALVES OF A DEPLOY (alchemy 2.0.0-beta.79):
9
+ * · PLAN, A COLD ADOPTION. The probe mints an instance id no row can hold (Plan.ts
10
+ * `generateInstanceId()`), and `diff` then runs with that id and the attributes the probe
11
+ * read. So no row holds the id.
12
+ * · APPLY, THE SAME ADOPTION. Apply commits an `updating` row with `adopting: true` under that id
13
+ * BEFORE it calls reconcile, and the flag stays until the update commits (Apply.ts). An
14
+ * adoption whose apply was interrupted keeps it, so the next plan's `diff` sees it too.
15
+ * · A RECOVERED CREATE. A `creating` row with no attributes, whose live object the recovery read
16
+ * found. It is this resource's own create when the row proves it (probe.ts `provenOurs`, the
17
+ * same proof the read asked). Otherwise the plan went on only because `--adopt` said so — an
18
+ * adoption like any other.
19
+ * · Any other row with attributes is state vouching for the object: not an adoption.
20
+ * ⚠️ OUTSIDE A STACK — no Stack or State in context, or a store that errors (rows.ts) — no row
21
+ * holds anything, so every call is an adoption: the strict answer, never the permissive one. A
22
+ * family then refuses an ordinary update's write as if it were an adoption's. `alchemy deploy`
23
+ * and the Alchemist session provide both services.
24
+ */
25
+ import * as Effect from 'effect/Effect';
26
+ import type { Owner } from './adopt.ts';
27
+ import { provenOurs } from './probe.ts';
28
+ import { recordedGeneration } from './rows.ts';
29
+
30
+ /**
31
+ * Whether `owner`'s call is an adoption (header). `settled` is the family's "live matches these
32
+ * props", asked only for a recovered create, with the props of the row that recorded it.
33
+ * ⛔ A CREATE IS NEVER ONE HERE. `output` undefined is reconcile's create path, which has its own
34
+ * rule for a live object it finds (adopt.ts `refuseTakeover`).
35
+ */
36
+ export const adopting = <E, R>(
37
+ owner: Owner,
38
+ settled: (recorded: unknown) => Effect.Effect<boolean, E, R>,
39
+ ): Effect.Effect<boolean, never, R> =>
40
+ Effect.gen(function* () {
41
+ if (owner.output === undefined) return false;
42
+ const generation = yield* recordedGeneration(owner.fqn, owner.instanceId);
43
+ if (generation === undefined || generation.adopting === true) return true;
44
+ if (generation.attr !== undefined && generation.attr !== null) return false;
45
+ return !(yield* provenOurs(owner, settled(generation.props)));
46
+ });
@@ -7,46 +7,13 @@
7
7
  import { describe, expect, test } from 'bun:test';
8
8
  import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
9
9
  import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
10
- import { InMemoryService } from 'alchemy/State/InMemoryState';
11
10
  import type { ResourceState } from 'alchemy/State/ResourceState';
12
- import { State } from 'alchemy/State/State';
13
- import { Stack } from 'alchemy/Stack';
14
11
  import * as Effect from 'effect/Effect';
15
12
  import { refuseTakeover } from './adopt.ts';
16
13
  import { ownedRead } from './probe.ts';
17
14
  import { noteResume, noteUnfinished, resumes } from './resume.ts';
18
15
  import { forgetRefusedCreate, isCreate, recordedInstance } from './rows.ts';
19
-
20
- const row = (fqn: string, instanceId: string, old?: unknown, props: unknown = { name: 'a' }) =>
21
- ({
22
- fqn,
23
- instanceId,
24
- old,
25
- props,
26
- status: old === undefined ? 'creating' : 'replacing',
27
- }) as unknown as ResourceState;
28
-
29
- /**
30
- * A stack `s` at stage `test` whose store holds `rows`: `A` and `B` declared with a `name`, and `Z`
31
- * declared `renamedFrom('X')`.
32
- */
33
- const withStore = <A>(rows: Record<string, ResourceState>, effect: Effect.Effect<A>): Promise<A> =>
34
- Effect.runPromise(
35
- effect.pipe(
36
- Effect.provideService(State, InMemoryService({ s: { test: rows } })),
37
- Effect.provideService(Stack, {
38
- actions: {},
39
- bindings: {},
40
- name: 's',
41
- resources: {
42
- A: { Props: { name: 'a' } },
43
- B: { Props: { name: 'b' } },
44
- Z: { FormerFqns: ['X'], Props: { name: 'a' } },
45
- },
46
- stage: 'test',
47
- } as never),
48
- ),
49
- );
16
+ import { row, withStore } from './store-fixture.ts';
50
17
 
51
18
  describe('recordedInstance', () => {
52
19
  test('is the row at the FQN, any generation of its `old` chain, or a renamedFrom row', async () => {
@@ -28,29 +28,28 @@ import type { Owner } from './adopt.ts';
28
28
  import { recordedInstance } from './rows.ts';
29
29
 
30
30
  /**
31
- * A family's `read` answer. With attributes in state it is `found` as is. Without, it is the probe
32
- * or the recovery read (header): `found` is ours only when the state store records this instance
33
- * with its whole declaration AND `settled` — the family's own "live matches these props" — says so.
34
- * ⛔ `settled` NEVER FAILS THE READ. The recovery read runs with the INTERRUPTED deploy's props, so
31
+ * Whether the state store proves the live object this instance's own: a row records `ask.instanceId`
32
+ * with its whole declaration (whole.ts) AND `settled` — the family's own "live matches these props"
33
+ * — says so. The one proof ownership/ accepts for a live object state holds no attributes for; the
34
+ * recovery read (`ownedRead`) and an adoption check (adopting.ts) both ask it.
35
+ * ⛔ `settled` NEVER FAILS THE CALLER. The recovery read runs with the INTERRUPTED deploy's props, so
35
36
  * a declaration that cannot be evaluated any more (a fragments directory since moved, a group
36
37
  * name since renamed) would fail every later plan, the fix included. It reads as "not proven
37
- * ours" instead — `Unowned`, which `--adopt` resolves — with the reason logged.
38
+ * ours" instead, with the reason logged.
38
39
  */
39
- export const ownedRead = <A extends object, E, R>(
40
- ask: Owner,
41
- found: A | undefined,
40
+ export const provenOurs = <E, R>(
41
+ ask: { readonly fqn: string; readonly instanceId: string },
42
42
  settled: Effect.Effect<boolean, E, R>,
43
- ): Effect.Effect<A | undefined, never, R> =>
43
+ ): Effect.Effect<boolean, never, R> =>
44
44
  Effect.gen(function* () {
45
- if (found === undefined || ask.output !== undefined) return found;
46
45
  const recorded = yield* recordedInstance(ask.fqn, ask.instanceId);
47
- if (recorded === 'absent') return Unowned(found);
46
+ if (recorded === 'absent') return false;
48
47
  if (recorded === 'partial') {
49
48
  yield* Effect.logWarning(
50
49
  `${ask.fqn}: the interrupted create's state row lacks part of the declaration (a prop ` +
51
50
  'was still an Output when it was written), so the live object is not proven ours',
52
51
  );
53
- return Unowned(found);
52
+ return false;
54
53
  }
55
54
  const unproven = (reason: unknown) =>
56
55
  Effect.as(
@@ -60,6 +59,20 @@ export const ownedRead = <A extends object, E, R>(
60
59
  ),
61
60
  false,
62
61
  );
63
- const ours = yield* settled.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
64
- return ours ? found : Unowned(found);
62
+ return yield* settled.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
63
+ });
64
+
65
+ /**
66
+ * A family's `read` answer. With attributes in state it is `found` as is. Without, it is the probe
67
+ * or the recovery read (header): `found` is ours only when `provenOurs` says so, else `Unowned`,
68
+ * which `--adopt` resolves.
69
+ */
70
+ export const ownedRead = <A extends object, E, R>(
71
+ ask: Owner,
72
+ found: A | undefined,
73
+ settled: Effect.Effect<boolean, E, R>,
74
+ ): Effect.Effect<A | undefined, never, R> =>
75
+ Effect.gen(function* () {
76
+ if (found === undefined || ask.output !== undefined) return found;
77
+ return (yield* provenOurs(ask, settled)) ? found : Unowned(found);
65
78
  });
@@ -31,12 +31,15 @@ const storeOf = Effect.gen(function* () {
31
31
  const rowAt = (store: StateService, where: Where, fqn: string) =>
32
32
  store.get({ ...where, fqn }).pipe(Effect.orElseSucceed(() => undefined));
33
33
 
34
- type Generation = {
34
+ /** One generation of a persisted row, as far as ownership/ reads it. */
35
+ export type Generation = {
35
36
  readonly instanceId?: unknown;
36
37
  readonly status?: unknown;
37
38
  readonly props?: unknown;
38
39
  readonly attr?: unknown;
39
40
  readonly old?: unknown;
41
+ /** Set by Apply on the `updating` row of an adoption, until that update commits (Apply.ts). */
42
+ readonly adopting?: unknown;
40
43
  };
41
44
 
42
45
  /** The generation of a persisted row that carries `instanceId`: the row itself, or one in `old`. */
@@ -51,7 +54,10 @@ const generationOf = (row: unknown, instanceId: string): Generation | undefined
51
54
  };
52
55
 
53
56
  /** The generation `instanceId` names — at the FQN, or at a former FQN `renamedFrom` names. */
54
- const recordedGeneration = (fqn: string, instanceId: string) =>
57
+ export const recordedGeneration = (
58
+ fqn: string,
59
+ instanceId: string,
60
+ ): Effect.Effect<Generation | undefined> =>
55
61
  Effect.gen(function* () {
56
62
  const found = yield* storeOf;
57
63
  if (found === undefined) return undefined;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A state store and a Stack for the ownership/ unit tests — the two services rows.ts reads.
3
+ *
4
+ * ⛔ TEST-ONLY. No provider imports this file and it is not on any barrel.
5
+ */
6
+ import { InMemoryService } from 'alchemy/State/InMemoryState';
7
+ import type { ResourceState } from 'alchemy/State/ResourceState';
8
+ import { State } from 'alchemy/State/State';
9
+ import { Stack } from 'alchemy/Stack';
10
+ import * as Effect from 'effect/Effect';
11
+
12
+ /** A row with no attributes: `creating`, or `replacing` when it carries an `old` generation. */
13
+ export const row = (
14
+ fqn: string,
15
+ instanceId: string,
16
+ old?: unknown,
17
+ props: unknown = { name: 'a' },
18
+ ): ResourceState =>
19
+ ({
20
+ fqn,
21
+ instanceId,
22
+ old,
23
+ props,
24
+ status: old === undefined ? 'creating' : 'replacing',
25
+ }) as unknown as ResourceState;
26
+
27
+ /**
28
+ * A stack `s` at stage `test` whose store holds `rows`: `A` and `B` declared with a `name`, and `Z`
29
+ * declared `renamedFrom('X')`.
30
+ */
31
+ export const withStore = <A>(
32
+ rows: Record<string, ResourceState>,
33
+ effect: Effect.Effect<A>,
34
+ ): Promise<A> =>
35
+ Effect.runPromise(
36
+ effect.pipe(
37
+ Effect.provideService(State, InMemoryService({ s: { test: rows } })),
38
+ Effect.provideService(Stack, {
39
+ actions: {},
40
+ bindings: {},
41
+ name: 's',
42
+ resources: {
43
+ A: { Props: { name: 'a' } },
44
+ B: { Props: { name: 'b' } },
45
+ Z: { FormerFqns: ['X'], Props: { name: 'a' } },
46
+ },
47
+ stage: 'test',
48
+ } as never),
49
+ ),
50
+ );
@@ -73,6 +73,38 @@ describe('adopting a Ceph pool', () => {
73
73
  expect(fake.calls.every((call) => call.path === STATUS)).toBe(true);
74
74
  });
75
75
 
76
+ /**
77
+ * ★ THE RECHECK MUST NOT FAIL A FIELD THE FAMILY DOES NOT MANAGE. `pg_num` is create-time only and
78
+ * the autoscaler owns it, so a declared birth size differs from the live count and shows under
79
+ * `changed`. recheck.ts asks the diff again with the live value as recorded props; this diff
80
+ * re-reads the cluster and ignores `olds`, so it says `noop` again, and the deploy only reads.
81
+ */
82
+ test('declaring a field it does not manage: rechecked, still noop, and the deploy only reads', async () => {
83
+ const fake = cluster(3);
84
+ const birth = () =>
85
+ ProxmoxCephPool('cephtb4', {
86
+ min_size: 2,
87
+ name: 'cephtb4',
88
+ node: 'n2',
89
+ pg_num: 32,
90
+ size: 3,
91
+ target: FAKE_TARGET,
92
+ });
93
+ await withoutBao(async () => {
94
+ const engine = engineFor(fake);
95
+ const report = await engine.verify(birth());
96
+ expect(report.rows[0]).toMatchObject({
97
+ changed: ['pg_num'],
98
+ diff: 'noop',
99
+ ok: true,
100
+ recheck: 'noop',
101
+ });
102
+ expect(report.rows[0]?.why).toContain('does not manage');
103
+ expect(await engine.deploy(birth())).toEqual({ cephtb4: 'adopted' });
104
+ });
105
+ expect(fake.writes()).toEqual([]);
106
+ });
107
+
76
108
  test('that drifted: the verifier names the field, and the deploy PUTs once', async () => {
77
109
  const fake = cluster(2);
78
110
  await withoutBao(async () => {
@@ -34,6 +34,11 @@ export type FakePve = {
34
34
  * keyed `node/vmid` — a guest that appears between a plan and its deploy.
35
35
  */
36
36
  readonly vanish: Map<string, number>;
37
+ /**
38
+ * Hand edits that land mid-deploy, keyed `node/vmid`: once that guest's config has been read
39
+ * `after` more times, `set` is merged into it — a change made between a plan and its apply.
40
+ */
41
+ readonly edits: Map<string, { after: number; readonly set: Record<string, unknown> }>;
37
42
  /** Config keys a `PUT …/config` accepts with 200 and then does not store. */
38
43
  readonly ignore: Set<string>;
39
44
  /** How every task ends, and whether a task-starting call answers with no UPID at all. */
@@ -90,6 +95,7 @@ export const fakePve = (): FakePve => {
90
95
  const others: { vmid: number | string; node: string; type: string }[] = [];
91
96
  const configErrors = new Map<string, { status: number; body: unknown }>();
92
97
  const vanish = new Map<string, number>();
98
+ const edits: FakePve['edits'] = new Map();
93
99
  const ignore = new Set<string>();
94
100
  const task = { exit: 'OK', noUpid: false };
95
101
  const seen: Seen[] = [];
@@ -148,7 +154,12 @@ export const fakePve = (): FakePve => {
148
154
  const message = `Configuration file 'nodes/${node}/lxc/${vmid}.conf' does not exist\n`;
149
155
  return json({ data: null, message }, 500);
150
156
  }
151
- if (tail === '/config' && request.method === 'GET') return json({ data: config });
157
+ if (tail === '/config' && request.method === 'GET') {
158
+ const answer = json({ data: config });
159
+ const edit = edits.get(`${node}/${vmid}`);
160
+ if (edit !== undefined && --edit.after === 0) Object.assign(config, edit.set);
161
+ return answer;
162
+ }
152
163
  if (tail === '/config' && request.method === 'PUT') {
153
164
  const digest = form.get('digest');
154
165
  if (digest !== null && digest !== config['digest']) {
@@ -192,6 +203,7 @@ export const fakePve = (): FakePve => {
192
203
  };
193
204
  return {
194
205
  configErrors,
206
+ edits,
195
207
  fetch: Object.assign(remapped, { preconnect: globalThis.fetch.preconnect }),
196
208
  guests,
197
209
  ignore,
@@ -4,28 +4,20 @@
4
4
  *
5
5
  * ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
6
6
  * its deploy writes NOTHING, and the next plan is `noop`; the create spellings (`storage:size`,
7
- * a NIC without its MAC) adopt as cleanly; drift is named at plan and written as one PUT that
8
- * keeps the live MAC and carries the digest; and every change PVE cannot make in place fails the
9
- * plan with nothing written. Deleting both empty-change guards (reconcile's in lxc.ts and
10
- * `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in lxc-wire.ts, fails a test here —
11
- * each MEASURED by making that edit and running this file, 2026-09-21.
7
+ * a NIC without its MAC) adopt as cleanly; after that adoption, drift is named at plan and
8
+ * written as one PUT that keeps the live MAC and carries the digest; and every change PVE cannot
9
+ * make in place fails the plan with nothing written. Deleting both empty-change guards
10
+ * (reconcile's in lxc.ts and `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
11
+ * lxc-wire.ts, fails a test here — each MEASURED by making that edit and running this file,
12
+ * 2026-09-21. An adoption that WOULD write fails instead: lxc-strict-adopt.test.ts.
12
13
  */
13
14
  import { afterEach, describe, expect, test } from 'bun:test';
14
15
  import { adopt } from 'alchemy/AdoptPolicy';
15
16
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
16
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
17
+ import { NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
17
18
  import { ProxmoxLxc } from './lxc.ts';
18
19
  import type { LxcProps } from './lxc-props.ts';
19
20
 
20
- const NODE = 'pve1';
21
- const VMID = 100;
22
-
23
- /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
24
- const pasted = (): LxcProps => {
25
- const { lxc: _raw, ...config } = LIVE;
26
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID };
27
- };
28
-
29
21
  const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
30
22
 
31
23
  let fake: FakePve | undefined;
@@ -84,17 +76,17 @@ describe('adopting a live container', () => {
84
76
  });
85
77
  });
86
78
 
87
- describe('drift at adoption is named at plan and written once', () => {
79
+ describe('drift after the adoption is named at plan and written once', () => {
88
80
  test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
89
81
  const pve = live();
90
- const stack = lxcEngine(pve);
82
+ const stack = await adoptedAsIs(pve);
91
83
  const props: LxcProps = {
92
84
  ...pasted(),
93
85
  memory: 4096,
94
86
  net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
95
87
  };
96
88
  const planned = await stack.plan(ct(props));
97
- expect(planned.actions).toEqual({ ct: 'adopted' });
89
+ expect(planned.actions).toEqual({ ct: 'update' });
98
90
  expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
99
91
  expect((await stack.deploy(ct(props))).failure).toBe('');
100
92
  expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
@@ -107,7 +99,7 @@ describe('drift at adoption is named at plan and written once', () => {
107
99
 
108
100
  test('a larger rootfs is a resize after the config, never a new volume', async () => {
109
101
  const pve = live();
110
- const stack = lxcEngine(pve);
102
+ const stack = await adoptedAsIs(pve);
111
103
  const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-100-disk-0,size=64G' };
112
104
  expect((await stack.deploy(ct(props))).failure).toBe('');
113
105
  expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
@@ -125,7 +117,8 @@ describe('changes PVE cannot make in place fail the plan and write nothing', ()
125
117
  ['detaching a mount point', { mp0: '' }, /unusedN/],
126
118
  ])('%s', async (_, change, reason) => {
127
119
  const pve = live();
128
- const run = await lxcEngine(pve).deploy(ct({ ...pasted(), ...change } as LxcProps));
120
+ const stack = await adoptedAsIs(pve);
121
+ const run = await stack.deploy(ct({ ...pasted(), ...change } as LxcProps));
129
122
  expect(run.failure).toMatch(reason);
130
123
  expect(writesOf(pve)).toEqual([]);
131
124
  });