@homeflare/alchemy 0.10.1 → 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 (42) 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/docs/adopted-deploys.md +4 -2
  24. package/docs/ownership.md +1 -1
  25. package/docs/proxmox-lxc-adopt.md +83 -0
  26. package/docs/proxmox-lxc.md +7 -45
  27. package/docs/proxmox.md +2 -0
  28. package/package.json +1 -1
  29. package/src/ownership/adopting.test.ts +63 -0
  30. package/src/ownership/adopting.ts +46 -0
  31. package/src/ownership/ownership.test.ts +1 -34
  32. package/src/ownership/probe.ts +27 -14
  33. package/src/ownership/rows.ts +8 -2
  34. package/src/ownership/store-fixture.ts +50 -0
  35. package/src/proxmox/fake-pve-lxc.ts +13 -1
  36. package/src/proxmox/lxc-adopt.test.ts +13 -20
  37. package/src/proxmox/lxc-adoption.ts +50 -0
  38. package/src/proxmox/lxc-guards.test.ts +6 -13
  39. package/src/proxmox/lxc-harness.ts +25 -2
  40. package/src/proxmox/lxc-ownership.test.ts +19 -14
  41. package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
  42. package/src/proxmox/lxc.ts +16 -12
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The strict adoption gate for `Proxmox.Lxc`: AN ADOPTION NEVER CHANGES A GUEST (decided
3
+ * 2026-09-21). A guest this stack adopts — no recorded instance, or one the plan only kept because
4
+ * `--adopt` said so (ownership/adopting.ts) — is taken over exactly as it runs, or not at all.
5
+ *
6
+ * ⛔ ANY DIFFERENCE FAILS, AT PLAN AND AGAIN AT APPLY. Before this, a cold adoption planned `adopted`
7
+ * whatever it would write (alchemy beta.79 prints no property diff for it) and only LOGGED the
8
+ * keys; `deploy --adopt --yes` then wrote them. A `net0` pasted without the live `tag=` moved the
9
+ * guest off its VLAN on the deploy that was meant to change nothing. Now the plan fails naming
10
+ * the keys, and reconcile re-asks against a fresh read, so a hand edit between plan and deploy
11
+ * is refused too rather than written back.
12
+ * ⛔ KEYS ONLY, NEVER VALUES. The refusal names what differs and nothing it differs by: a live
13
+ * config can carry anything (`description` is where people paste credentials), and plan output
14
+ * lands in CI logs.
15
+ * ★ ASKED BEFORE `judge`'s OWN REFUSALS. Those name a declared value (the `pct set` to run), and
16
+ * for an adoption the fix is the other direction: declare what is live.
17
+ * ★ THE STORE IS READ ONLY WHEN SOMETHING DIFFERS. A clean adoption — the only kind that proceeds
18
+ * — costs nothing here.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import type { Owner } from '../ownership/adopt.ts';
22
+ import { type LxcChange } from './lxc-judge.ts';
23
+ import { LxcRefusedError } from './lxc-lifecycle.ts';
24
+ import type { LxcProps } from './lxc-props.ts';
25
+ /** The refusal: keys, where, and what to do — never a value. */
26
+ export declare const adoptionRefusal: (props: LxcProps, drift: readonly string[]) => string;
27
+ /**
28
+ * Fails with `adoptionRefusal` when `change` would write to a guest `owner`'s call adopts.
29
+ * `settled` for a recovered create is the same judge, of the recording row's props against `live`.
30
+ */
31
+ export declare const refuseAdoptedDrift: (owner: Owner, props: LxcProps, live: Record<string, unknown>, change: LxcChange) => Effect.Effect<void, LxcRefusedError>;
32
+ //# sourceMappingURL=lxc-adoption.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lxc-adoption.d.ts","sourceRoot":"","sources":["../../src/proxmox/lxc-adoption.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AAEnD,OAAO,EAAE,KAAK,SAAS,EAAS,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,gEAAgE;AAChE,eAAO,MAAM,eAAe,UAAW,QAAQ,SAAS,SAAS,MAAM,EAAE,KAAG,MAIjE,CAAC;AAEZ;;;GAGG;AACH,eAAO,MAAM,kBAAkB,UACtB,KAAK,SACL,QAAQ,QACT,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,UACrB,SAAS,KAChB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAOlC,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import * as Effect from 'effect/Effect';
2
2
  import type { PveTarget } from './credentials.ts';
3
3
  import { type FakePve } from './fake-pve-lxc.ts';
4
+ import type { LxcProps } from './lxc-props.ts';
4
5
  export declare const TARGET: PveTarget;
5
6
  /**
6
7
  * A PRODUCTION-SHAPED config — the same keys and the same spellings pvesh printed, PVE's defaults
@@ -10,6 +11,12 @@ export declare const TARGET: PveTarget;
10
11
  * the estate's real topology — sizes, VLANs and bridges included, not only addresses.
11
12
  */
12
13
  export declare const LIVE: Readonly<Record<string, unknown>>;
14
+ /** Where `seed` puts the production-shaped guest by default, and its `node/vmid` key. */
15
+ export declare const NODE = "pve1";
16
+ export declare const VMID = 100;
17
+ export declare const KEY: string;
18
+ /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
19
+ export declare const pasted: (over?: Partial<LxcProps>) => LxcProps;
13
20
  /** Put `config` live on the fake as `node/vmid`, with a digest as PVE would add. */
14
21
  export declare const seed: (fake: FakePve, node: string, vmid: number, config?: Readonly<Record<string, unknown>>) => void;
15
22
  /** What the CLI would set for one run: `adopt` is `--adopt`. */
@@ -31,4 +38,9 @@ export declare const lxcEngine: (fake: FakePve) => {
31
38
  /** The stored row's status, or undefined when there is none. */
32
39
  status: (fqn: string) => "created" | "creating" | "deleting" | "replaced" | "replacing" | "updated" | "updating" | undefined;
33
40
  };
41
+ /**
42
+ * The engine, with the guest `seed` put at NODE/VMID adopted exactly as it runs — the only adoption
43
+ * that proceeds (lxc-adoption.ts) — so a test can go on to change it, as an ordinary update.
44
+ */
45
+ export declare const adoptedAsIs: (pve: FakePve) => Promise<ReturnType<typeof lxcEngine>>;
34
46
  //# sourceMappingURL=lxc-harness.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lxc-harness.d.ts","sourceRoot":"","sources":["../../src/proxmox/lxc-harness.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAyB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGxE,eAAO,MAAM,MAAM,EAAE,SAIpB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAsBlD,CAAC;AAEF,oFAAoF;AACpF,eAAO,MAAM,IAAI,SAAU,OAAO,QAAQ,MAAM,QAAQ,MAAM,qDAE7D,CAAC;AAQF,gEAAgE;AAChE,MAAM,MAAM,UAAU,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtD,MAAM,MAAM,GAAG,GAAG;IAChB,qFAAqF;IACrF,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,qCAAqC;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC,CAAC;AAIF,eAAO,MAAM,SAAS,SAAU,OAAO;IA8CnC,yFAAyF;IACzF,IAAI,YAAY,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,YAAW,UAAU;IAE7E,wBAAwB;IACxB,MAAM,YAAY,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,YAAW,UAAU;IAE/E,gEAAgE;IAChE,MAAM,QAAQ,MAAM;CAEvB,CAAC"}
1
+ {"version":3,"file":"lxc-harness.d.ts","sourceRoot":"","sources":["../../src/proxmox/lxc-harness.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAyB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG/C,eAAO,MAAM,MAAM,EAAE,SAIpB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAsBlD,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,IAAI,SAAS,CAAC;AAC3B,eAAO,MAAM,IAAI,MAAM,CAAC;AACxB,eAAO,MAAM,GAAG,QAA4B,CAAC;AAE7C,6FAA6F;AAC7F,eAAO,MAAM,MAAM,UAAU,OAAO,CAAC,QAAQ,CAAC,KAAQ,QAGrD,CAAC;AAEF,oFAAoF;AACpF,eAAO,MAAM,IAAI,SAAU,OAAO,QAAQ,MAAM,QAAQ,MAAM,qDAE7D,CAAC;AAQF,gEAAgE;AAChE,MAAM,MAAM,UAAU,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtD,MAAM,MAAM,GAAG,GAAG;IAChB,qFAAqF;IACrF,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,qCAAqC;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC,CAAC;AAIF,eAAO,MAAM,SAAS,SAAU,OAAO;IA8CnC,yFAAyF;IACzF,IAAI,YAAY,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,YAAW,UAAU;IAE7E,wBAAwB;IACxB,MAAM,YAAY,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,YAAW,UAAU;IAE/E,gEAAgE;IAChE,MAAM,QAAQ,MAAM;CAEvB,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,WAAW,QAAe,OAAO,KAAG,OAAO,CAAC,UAAU,CAAC,OAAO,SAAS,CAAC,CAKpF,CAAC"}
@@ -17,11 +17,11 @@
17
17
  * ⛔ AND NOTHING HERE WRITES A VOLUME IT DID NOT ALLOCATE. A create takes `storage:GiB` only
18
18
  * (lxc-create-form.ts), and a deploy that planned a create never writes onto a guest it finds.
19
19
  *
20
- * ⚠️ AN ADOPTION ALWAYS PLANS `adopted`, EVEN WHEN IT WOULD WRITE. alchemy 2.0.0-beta.79 turns a
21
- * cold adoption's diff into an update whatever it answered (Plan.ts, `forceUpdateAfterAdoption`)
22
- * and prints no property diff for it. So `diff` LOGS the keys a deploy would write, by name
23
- * (never by value), as a warning — the only place a plan can say the declaration is not the live
24
- * config yet. A clean adoption logs nothing, and its deploy writes nothing (lxc-judge.ts).
20
+ * ⛔ AND AN ADOPTION NEVER CHANGES A GUEST (lxc-adoption.ts). A guest this stack adopts is taken
21
+ * over exactly as it runs: any key the declaration says otherwise FAILS the plan, by name (never
22
+ * by value), and reconcile asks again before it would write. Only an exact match adopts.
23
+ * ⚠️ AN UPDATE STILL PLANS `update` WITHOUT A PROPERTY DIFF, so `diff` LOGS the keys a deploy would
24
+ * write, by name, as a warning — the only place a plan says what the deploy will change.
25
25
  *
26
26
  * ⚠️ POWER STATE IS NOT DECLARED. `onboot` is config; start and stop are an operator's act, as for
27
27
  * `Proxmox.Vm`. `start: 1` starts a guest once, after the create that built it, and never again.
@@ -1 +1 @@
1
- {"version":3,"file":"lxc.d.ts","sourceRoot":"","sources":["../../src/proxmox/lxc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAiB7C,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE9D,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,QAAQ,EACR,aAAa,EACb,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,8FAA8F;AAC9F,eAAO,MAAM,UAAU,6CAA0E,CAAC;AA4KlG,eAAO,MAAM,kBAAkB,gIAI5B,CAAC"}
1
+ {"version":3,"file":"lxc.d.ts","sourceRoot":"","sources":["../../src/proxmox/lxc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAkB7C,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE9D,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,QAAQ,EACR,aAAa,EACb,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,8FAA8F;AAC9F,eAAO,MAAM,UAAU,6CAA0E,CAAC;AA+KlG,eAAO,MAAM,kBAAkB,gIAI5B,CAAC"}
@@ -57,8 +57,10 @@ lease (`readRole`, resource.ts). That is still a read.
57
57
  ² The PVE notification endpoints: `smtp` (the mail target), `sendmail`, `gotify`, `webhook`. The
58
58
  secrets are never props (notification-target.ts), so no write is ever needed to "reset" them.
59
59
  ³ Rewritten 2026-09-21 (PR 80, [proxmox-lxc.md](./proxmox-lxc.md)): its own reconcile, and its
60
- probe answers `Unowned`, so the deploy needs `--adopt`. Its `diff` also logs the keys a deploy
61
- would write, by name. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with no write").
60
+ probe answers `Unowned`, so the deploy needs `--adopt`. An adoption that would write fails the
61
+ plan instead, naming the keys ([proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md)), so only the
62
+ no-write row applies. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with no write") and
63
+ `src/proxmox/lxc-strict-adopt.test.ts`.
62
64
  ⁴ `updateBody` sends `target_size_ratio`, but `matches` does not compare it (float equality). A
63
65
  declared ratio that differs from the live one is never written on a no-op adoption and never
64
66
  reported as a diff.
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.1",
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
+ );