@homeflare/alchemy 0.8.0 → 0.10.1

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 (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The adoption gate, through Alchemy's real plan and apply over a fake PVE holding a
3
+ * production-shaped guest (placeholders only).
4
+ *
5
+ * ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
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.
12
+ */
13
+ import { afterEach, describe, expect, test } from 'bun:test';
14
+ import { adopt } from 'alchemy/AdoptPolicy';
15
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
16
+ import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
17
+ import { ProxmoxLxc } from './lxc.ts';
18
+ import type { LxcProps } from './lxc-props.ts';
19
+
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
+ const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
30
+
31
+ let fake: FakePve | undefined;
32
+ afterEach(() => {
33
+ fake?.stop();
34
+ fake = undefined;
35
+ });
36
+ const live = () => {
37
+ fake = fakePve();
38
+ seed(fake, NODE, VMID);
39
+ return fake;
40
+ };
41
+
42
+ describe('adopting a live container', () => {
43
+ test('the pasted config plans adopted, deploys with no write, and then plans noop', async () => {
44
+ const pve = live();
45
+ const stack = lxcEngine(pve);
46
+ const first = await stack.plan(ct(pasted()));
47
+ expect(first).toEqual({ actions: { ct: 'adopted' }, failure: '', warnings: [] });
48
+ const deployed = await stack.deploy(ct(pasted()));
49
+ expect(deployed.failure).toBe('');
50
+ expect(writesOf(pve)).toEqual([]);
51
+ expect(stack.status('ct')).toBe('updated');
52
+ expect(await stack.plan(ct(pasted()))).toEqual({
53
+ actions: { ct: 'noop' },
54
+ failure: '',
55
+ warnings: [],
56
+ });
57
+ });
58
+
59
+ test('create spellings and PVE defaults compare equal to the live config', async () => {
60
+ const stack = lxcEngine(live());
61
+ const props: LxcProps = {
62
+ ...pasted(),
63
+ mp0: 'tank:200,mp=/data',
64
+ net0: 'bridge=vmbr0,name=eth0,gw=192.0.2.1,ip=192.0.2.10/24,mtu=1500,tag=42',
65
+ net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=1500,firewall=0',
66
+ onboot: true,
67
+ rootfs: 'local-zfs:40',
68
+ tags: 'WEB',
69
+ unprivileged: true,
70
+ };
71
+ expect(await stack.plan(ct(props))).toEqual({
72
+ actions: { ct: 'adopted' },
73
+ failure: '',
74
+ warnings: [],
75
+ });
76
+ });
77
+
78
+ test('a declaration naming only node and vmid adopts without touching anything', async () => {
79
+ const pve = live();
80
+ const stack = lxcEngine(pve);
81
+ const deployed = await stack.deploy(ct({ node: NODE, target: TARGET, vmid: VMID }));
82
+ expect(deployed.failure).toBe('');
83
+ expect(writesOf(pve)).toEqual([]);
84
+ });
85
+ });
86
+
87
+ describe('drift at adoption is named at plan and written once', () => {
88
+ test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
89
+ const pve = live();
90
+ const stack = lxcEngine(pve);
91
+ const props: LxcProps = {
92
+ ...pasted(),
93
+ memory: 4096,
94
+ net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
95
+ };
96
+ const planned = await stack.plan(ct(props));
97
+ expect(planned.actions).toEqual({ ct: 'adopted' });
98
+ expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
99
+ expect((await stack.deploy(ct(props))).failure).toBe('');
100
+ expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
101
+ const put = pve.seen.find((call) => call.method === 'PUT')?.form;
102
+ expect(put?.get('memory')).toBe('4096');
103
+ expect(put?.get('net1')).toContain('hwaddr=00:00:5E:00:53:02');
104
+ expect(put?.get('digest')).toBe('digest-live');
105
+ expect((await stack.plan(ct(props))).actions).toEqual({ ct: 'noop' });
106
+ });
107
+
108
+ test('a larger rootfs is a resize after the config, never a new volume', async () => {
109
+ const pve = live();
110
+ const stack = lxcEngine(pve);
111
+ const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-100-disk-0,size=64G' };
112
+ expect((await stack.deploy(ct(props))).failure).toBe('');
113
+ expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
114
+ expect(pve.guests.get(`${NODE}/${String(VMID)}`)?.['rootfs']).toContain('size=64G');
115
+ });
116
+ });
117
+
118
+ describe('changes PVE cannot make in place fail the plan and write nothing', () => {
119
+ test.each([
120
+ ['a smaller mount point', { mp0: 'tank:subvol-100-disk-0,mp=/data,size=100G' }, /shrink/],
121
+ ['a storage move', { rootfs: 'other:40' }, /pct move-volume/],
122
+ ['an unprivileged flip', { unprivileged: 0 as const }, /read-only option/],
123
+ ['a device passthrough change', { dev1: '/dev/fuse' }, /pct set 100 --dev1/],
124
+ ['a feature beyond nesting', { features: 'nesting=1,keyctl=1' }, /root@pam/],
125
+ ['detaching a mount point', { mp0: '' }, /unusedN/],
126
+ ])('%s', async (_, change, reason) => {
127
+ const pve = live();
128
+ const run = await lxcEngine(pve).deploy(ct({ ...pasted(), ...change } as LxcProps));
129
+ expect(run.failure).toMatch(reason);
130
+ expect(writesOf(pve)).toEqual([]);
131
+ });
132
+
133
+ test('a vmid held elsewhere in the cluster fails the plan instead of planning a create', async () => {
134
+ const pve = live();
135
+ pve.others.push({ node: 'pve2', type: 'qemu', vmid: 101 });
136
+ const run = await lxcEngine(pve).plan(ct({ ...pasted(), vmid: 101 }));
137
+ expect(run.failure).toContain('vmid 101 is a qemu on pve2');
138
+ expect(writesOf(pve)).toEqual([]);
139
+ });
140
+ });
141
+
142
+ describe('a guest another tool moved', () => {
143
+ const migrate = (pve: FakePve, to: string) => {
144
+ const config = pve.guests.get(`${NODE}/${String(VMID)}`) ?? {};
145
+ pve.guests.delete(`${NODE}/${String(VMID)}`);
146
+ pve.guests.set(`${to}/${String(VMID)}`, config);
147
+ };
148
+
149
+ test('after HA moves it, the old node fails the plan; the new node is a write-free update', async () => {
150
+ const pve = live();
151
+ const stack = lxcEngine(pve);
152
+ await stack.deploy(ct(pasted()));
153
+ migrate(pve, 'pve2');
154
+ expect((await stack.plan(ct(pasted()))).failure).toContain('is a lxc on pve2');
155
+ const follow = await stack.deploy(ct({ ...pasted(), node: 'pve2' }));
156
+ expect(follow.actions).toEqual({ ct: 'update' });
157
+ expect(follow.warnings.join('\n')).toContain('on pve2 now, not pve1');
158
+ expect(writesOf(pve)).toEqual([]);
159
+ expect((await stack.plan(ct({ ...pasted(), node: 'pve2' }))).actions).toEqual({ ct: 'noop' });
160
+ });
161
+
162
+ test('naming another node the guest is not on is refused, never a second guest', async () => {
163
+ const pve = live();
164
+ const stack = lxcEngine(pve);
165
+ await stack.deploy(ct(pasted()));
166
+ const run = await stack.deploy(ct({ ...pasted(), node: 'pve2' }));
167
+ expect(run.failure).toContain('is a lxc on pve1, not a container on pve2');
168
+ expect(writesOf(pve)).toEqual([]);
169
+ });
170
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Creating a container: what an API token may create, and the body it POSTs.
3
+ *
4
+ * ★ SPLIT FROM lxc-judge.ts AT A REAL SEAM: the judge compares a declaration with a guest that
5
+ * exists; this file answers for a guest that does not, where there is nothing to compare with
6
+ * and the only safe answer to a doubtful key is to refuse it before the POST.
7
+ * ⛔ THE ROOT@PAM RULES ARE THE JUDGE'S OWN (`rootOnly`, `featuresNeedRoot`), imported rather than
8
+ * restated, so the create and the update cannot disagree about what a token may write.
9
+ */
10
+ import { featureWhy, featuresNeedRoot, rootOnly } from './lxc-judge.ts';
11
+ import {
12
+ type LxcProps,
13
+ declaredKeys,
14
+ declaredValue,
15
+ indexedKey,
16
+ isManagedKey,
17
+ wireValue,
18
+ } from './lxc-props.ts';
19
+ import { isHostPath, parseVolume } from './lxc-volume.ts';
20
+ import { scalarForm } from './lxc-wire.ts';
21
+ import { bool } from './values.ts';
22
+
23
+ /**
24
+ * ⛔ A CREATE ALLOCATES NEW VOLUMES ONLY. pve-container's `create_disks` allocates `storage:GiB`
25
+ * and takes anything else as "use specified/existing volid", then `restore_archive` unpacks the
26
+ * template into the mounted rootfs. So a create naming `local-zfs:subvol-100-disk-0` — a pasted
27
+ * adoption, re-created after its config vanished while the volume survived (a `pct destroy`
28
+ * only WARNS when a volume will not delete) — unpacks a template over the data on it.
29
+ */
30
+ const existingVolume = (key: string, value: string) =>
31
+ `${key}: ${value} names an existing volume, and PVE would unpack the template onto it. A ` +
32
+ 'create allocates new volumes only: declare `storage:GiB`, and attach an existing volume by ' +
33
+ 'hand after the create.';
34
+
35
+ /** Why this declaration cannot be CREATED by an API token, if it cannot. */
36
+ export const createRefusals = (props: LxcProps): string[] => {
37
+ const refuse: string[] = [];
38
+ if (props.ostemplate === undefined || props.ostemplate === '') {
39
+ refuse.push(
40
+ `CT ${String(props.vmid)} does not exist on ${props.node}, and without \`ostemplate\` ` +
41
+ 'there is nothing to create it from. To adopt a guest, declare the node it is on.',
42
+ );
43
+ }
44
+ const unprivileged = props.unprivileged === undefined ? true : bool(props.unprivileged);
45
+ for (const key of declaredKeys(props)) {
46
+ const value = wireValue(declaredValue(props, key));
47
+ const family = indexedKey(key)?.[0];
48
+ // ⛔ THE SAME RULE AS `judge`: createForm sends every declared key, so an unmanaged one (a
49
+ // cast-in `password`, `restore`/`force`, `hookscript`) is refused here or it is POSTed.
50
+ if (!isManagedKey(key)) refuse.push(`${key}: not a config key this resource manages.`);
51
+ if (value === '') continue;
52
+ if (family === 'dev') refuse.push(rootOnly(props.vmid, key, value, 'Device passthrough.'));
53
+ const volume = family === 'mp' || key === 'rootfs' ? parseVolume(value) : undefined;
54
+ if (volume !== undefined && isHostPath(volume)) {
55
+ refuse.push(rootOnly(props.vmid, key, value, 'A bind or device mount point.'));
56
+ }
57
+ if (volume?.kind === 'volume') refuse.push(existingVolume(key, value));
58
+ if (key === 'features' && featuresNeedRoot('', value, unprivileged)) {
59
+ refuse.push(rootOnly(props.vmid, key, value, featureWhy(unprivileged)));
60
+ }
61
+ }
62
+ return refuse;
63
+ };
64
+
65
+ /** The create body: every declared key with a value, plus the create-time instructions. */
66
+ export const createForm = (props: LxcProps): Record<string, string> => {
67
+ const form: Record<string, string> = { vmid: String(props.vmid) };
68
+ if (props.ostemplate !== undefined) form['ostemplate'] = props.ostemplate;
69
+ if (props.start !== undefined) form['start'] = wireValue(props.start);
70
+ for (const key of declaredKeys(props)) {
71
+ const value = wireValue(declaredValue(props, key));
72
+ if (value === '') continue;
73
+ form[key] = key === 'features' ? scalarForm(key, value) : value;
74
+ }
75
+ return form;
76
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Declaring a NEW container, and removing one, through the real engine over fake-pve-lxc.ts.
3
+ *
4
+ * ⛔ WHAT THESE PIN: a create POSTs once, waits for its task, reads back what PVE allocated and
5
+ * then plans `noop` against it; a create an API token cannot finish (no template, device
6
+ * passthrough, a bind mount) fails before any write; dropping the declaration retains the guest;
7
+ * and only `RemovalPolicy.destroy()` sends the DELETE.
8
+ */
9
+ import { afterEach, describe, expect, test } from 'bun:test';
10
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
11
+ import * as Effect from 'effect/Effect';
12
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
13
+ import { TARGET, lxcEngine } from './lxc-harness.ts';
14
+ import { ProxmoxLxc } from './lxc.ts';
15
+ import type { LxcProps } from './lxc-props.ts';
16
+
17
+ const NODE = 'pve1';
18
+ const VMID = 150;
19
+
20
+ const door = (over: Partial<LxcProps> = {}): LxcProps => ({
21
+ cores: 1,
22
+ features: 'nesting=1',
23
+ hostname: 'door-example',
24
+ memory: 512,
25
+ net0: 'name=eth0,bridge=vmbr0,ip=192.0.2.20/24,gw=192.0.2.1',
26
+ node: NODE,
27
+ onboot: 1,
28
+ ostemplate: 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst',
29
+ rootfs: 'local-zfs:8',
30
+ start: 1,
31
+ tags: 'Vault;door',
32
+ target: TARGET,
33
+ vmid: VMID,
34
+ ...over,
35
+ });
36
+
37
+ let fake: FakePve | undefined;
38
+ afterEach(() => {
39
+ fake?.stop();
40
+ fake = undefined;
41
+ });
42
+ const empty = () => {
43
+ fake = fakePve();
44
+ return fake;
45
+ };
46
+
47
+ describe('creating a container', () => {
48
+ test('one POST, then the next plan is noop against what PVE allocated', async () => {
49
+ const pve = empty();
50
+ const stack = lxcEngine(pve);
51
+ const created = await stack.deploy(ProxmoxLxc('door', door()));
52
+ expect(created.actions).toEqual({ door: 'create' });
53
+ expect(created.failure).toBe('');
54
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
55
+ const post = pve.seen.find((call) => call.method === 'POST')?.form;
56
+ expect(post?.get('rootfs')).toBe('local-zfs:8');
57
+ expect(post?.get('start')).toBe('1');
58
+ expect(post?.get('password')).toBeNull();
59
+ const live = pve.guests.get(`${NODE}/${String(VMID)}`);
60
+ expect(live?.['rootfs']).toBe(`local-zfs:subvol-${String(VMID)}-disk-0,size=8G`);
61
+ expect(live?.['net0']).toContain('hwaddr=');
62
+ expect((await stack.plan(ProxmoxLxc('door', door()))).actions).toEqual({ door: 'noop' });
63
+ });
64
+
65
+ test.each([
66
+ ['no template', { ostemplate: '' }, /nothing to create it from/],
67
+ ['device passthrough', { dev0: '/dev/net/tun' }, /pct set 150 --dev0/],
68
+ ['a bind mount', { mp0: '/srv/share,mp=/share' }, /bind or device mount point/],
69
+ ['a feature beyond nesting', { features: 'nesting=1,keyctl=1' }, /other than nesting/],
70
+ ])('refused before any write: %s', async (_, over, reason) => {
71
+ const pve = empty();
72
+ const run = await lxcEngine(pve).deploy(ProxmoxLxc('door', door(over as Partial<LxcProps>)));
73
+ expect(run.failure).toMatch(reason);
74
+ expect(writesOf(pve)).toEqual([]);
75
+ });
76
+ });
77
+
78
+ describe('removing a container', () => {
79
+ test('retain is the default: dropping the declaration sends no DELETE', async () => {
80
+ const pve = empty();
81
+ const stack = lxcEngine(pve);
82
+ await stack.deploy(ProxmoxLxc('door', door()));
83
+ expect((await stack.deploy(Effect.void)).failure).toBe('');
84
+ expect(stack.status('door')).toBeUndefined();
85
+ expect(pve.guests.has(`${NODE}/${String(VMID)}`)).toBe(true);
86
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
87
+ });
88
+
89
+ test('RemovalPolicy.destroy() reaches the DELETE, and waits for its task', async () => {
90
+ const pve = empty();
91
+ const stack = lxcEngine(pve);
92
+ await stack.deploy(ProxmoxLxc('door', door()).pipe(RemovalPolicy.destroy()));
93
+ expect((await stack.deploy(Effect.void)).failure).toBe('');
94
+ expect(pve.guests.has(`${NODE}/${String(VMID)}`)).toBe(false);
95
+ expect(writesOf(pve)).toEqual([
96
+ `POST nodes/${NODE}/lxc`,
97
+ `DELETE nodes/${NODE}/lxc/${String(VMID)}`,
98
+ ]);
99
+ expect(pve.seen.some((call) => /\/tasks\/UPID:.*vzdestroy/.test(call.path))).toBe(true);
100
+ // ⛔ No `force` (stops a running guest), `purge` (drops it from HA and backup jobs) or
101
+ // `destroy-unreferenced-disks` (deletes volumes the config does not even name).
102
+ const del = pve.seen.find((call) => call.method === 'DELETE');
103
+ expect(del?.form.toString()).toBe('');
104
+ });
105
+
106
+ test('destroy() refuses a guest that no longer matches: the vmid may be somebody else’s', async () => {
107
+ const pve = empty();
108
+ const stack = lxcEngine(pve);
109
+ await stack.deploy(ProxmoxLxc('door', door()).pipe(RemovalPolicy.destroy()));
110
+ const live = pve.guests.get(`${NODE}/${String(VMID)}`) ?? {};
111
+ live['hostname'] = 'someone-else';
112
+ const run = await stack.deploy(Effect.void);
113
+ expect(run.failure).toContain('no longer matches its last declaration (hostname)');
114
+ expect(pve.guests.has(`${NODE}/${String(VMID)}`)).toBe(true);
115
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
116
+ });
117
+
118
+ test('a changed vmid is refused, never a second guest', async () => {
119
+ const pve = empty();
120
+ const stack = lxcEngine(pve);
121
+ await stack.deploy(ProxmoxLxc('door', door()));
122
+ const run = await stack.plan(ProxmoxLxc('door', door({ vmid: VMID + 1 })));
123
+ expect(run.failure).toContain('is a different guest');
124
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
125
+ });
126
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The guards an adversarial review found untested or missing, through the real engine over
3
+ * fake-pve-lxc.ts: each test here failed against a mutant of the guard it names (2026-09-21).
4
+ *
5
+ * ⛔ WHAT THESE PIN: a guest the cluster lost is named at plan before a deploy rebuilds it; a task
6
+ * that fails, or answers no UPID, is a failed deploy; a write PVE accepted but did not store is
7
+ * caught by the read-back; a new template is refused; an options change on a volume that also
8
+ * grows keeps the live size in the PUT and resizes after it; and a vmid PVE spells as a string
9
+ * is still "listed". Whose guest it is: lxc-ownership.test.ts.
10
+ */
11
+ import { afterEach, describe, expect, test } from 'bun:test';
12
+ import { adopt } from 'alchemy/AdoptPolicy';
13
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
14
+ import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
15
+ import { ProxmoxLxc } from './lxc.ts';
16
+ import type { LxcProps } from './lxc-props.ts';
17
+
18
+ const NODE = 'pve1';
19
+ const VMID = 100;
20
+ const KEY = `${NODE}/${String(VMID)}`;
21
+ const TEMPLATE = 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst';
22
+
23
+ const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
24
+ const { lxc: _raw, ...config } = LIVE;
25
+ return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
26
+ };
27
+
28
+ let fake: FakePve | undefined;
29
+ afterEach(() => {
30
+ fake?.stop();
31
+ fake = undefined;
32
+ });
33
+ const cluster = () => {
34
+ fake = fakePve();
35
+ return fake;
36
+ };
37
+
38
+ describe('a guest the cluster no longer lists, with state still held', () => {
39
+ test('with a template: the plan warns it will be created again, and the deploy does', async () => {
40
+ const pve = cluster();
41
+ seed(pve, NODE, VMID);
42
+ const stack = lxcEngine(pve);
43
+ // ⚠️ Without the devices (a token cannot create passthrough), and with the NEW-disk
44
+ // spellings, which compare equal to the live volumes but can only allocate fresh ones.
45
+ const rebuildable = pasted({
46
+ dev0: undefined,
47
+ dev1: undefined,
48
+ mp0: 'tank:200,mp=/data',
49
+ ostemplate: TEMPLATE,
50
+ rootfs: 'local-zfs:40',
51
+ });
52
+ const declared = () => ProxmoxLxc('ct', rebuildable).pipe(adopt(true));
53
+ expect((await stack.deploy(declared())).failure).toBe('');
54
+ pve.guests.delete(KEY);
55
+ const planned = await stack.plan(declared());
56
+ expect(planned.actions).toEqual({ ct: 'update' });
57
+ expect(planned.warnings.join('\n')).toContain('CREATES it again from ostemplate');
58
+ expect((await stack.deploy(declared())).failure).toBe('');
59
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
60
+ });
61
+
62
+ test('declared by live volume ids: refused, never a template unpacked onto them', async () => {
63
+ const pve = cluster();
64
+ seed(pve, NODE, VMID);
65
+ const stack = lxcEngine(pve);
66
+ const declared = () =>
67
+ ProxmoxLxc('ct', pasted({ dev0: undefined, dev1: undefined, ostemplate: TEMPLATE })).pipe(
68
+ adopt(true),
69
+ );
70
+ expect((await stack.deploy(declared())).failure).toBe('');
71
+ pve.guests.delete(KEY);
72
+ const run = await stack.deploy(declared());
73
+ expect(run.failure).toContain('rootfs: local-zfs:subvol-100-disk-0,size=40G names an existing');
74
+ expect(run.failure).toContain('mp0: tank:subvol-100-disk-0');
75
+ expect(writesOf(pve)).toEqual([]);
76
+ });
77
+
78
+ test('without one: the plan fails, rather than planning an update the deploy cannot make', async () => {
79
+ const pve = cluster();
80
+ seed(pve, NODE, VMID);
81
+ const stack = lxcEngine(pve);
82
+ expect((await stack.deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)))).failure).toBe('');
83
+ pve.guests.delete(KEY);
84
+ expect((await stack.plan(ProxmoxLxc('ct', pasted()))).failure).toContain(
85
+ 'nothing to create it from',
86
+ );
87
+ });
88
+ });
89
+
90
+ describe('a write is claimed only when PVE says it finished', () => {
91
+ const door = () =>
92
+ ProxmoxLxc('door', {
93
+ node: NODE,
94
+ ostemplate: TEMPLATE,
95
+ rootfs: 'local-zfs:8',
96
+ target: TARGET,
97
+ vmid: 150,
98
+ });
99
+
100
+ test('a create task that ends in an error fails the deploy, naming the task', async () => {
101
+ const pve = cluster();
102
+ pve.task.exit = 'unable to create CT 150 - command failed';
103
+ const run = await lxcEngine(pve).deploy(door());
104
+ expect(run.failure).toContain('ended "unable to create CT 150 - command failed"');
105
+ });
106
+
107
+ test('a create answered with no UPID fails the deploy', async () => {
108
+ const pve = cluster();
109
+ pve.task.noUpid = true;
110
+ expect((await lxcEngine(pve).deploy(door())).failure).toContain('returned no task id');
111
+ });
112
+
113
+ test('a config write PVE accepted but did not store fails the read-back', async () => {
114
+ const pve = cluster();
115
+ seed(pve, NODE, VMID);
116
+ pve.ignore.add('memory');
117
+ const run = await lxcEngine(pve).deploy(
118
+ ProxmoxLxc('ct', pasted({ memory: 4096 })).pipe(adopt(true)),
119
+ );
120
+ expect(run.failure).toContain('memory still differ');
121
+ });
122
+ });
123
+
124
+ describe('identity and volumes', () => {
125
+ test('another template for a guest that exists is refused at plan', async () => {
126
+ const pve = cluster();
127
+ seed(pve, NODE, VMID);
128
+ const stack = lxcEngine(pve);
129
+ await stack.deploy(ProxmoxLxc('ct', pasted({ ostemplate: TEMPLATE })).pipe(adopt(true)));
130
+ const run = await stack.plan(
131
+ ProxmoxLxc('ct', pasted({ ostemplate: 'local:vztmpl/other.tar.zst' })),
132
+ );
133
+ expect(run.failure).toContain('a template is consumed at create');
134
+ expect(writesOf(pve)).toEqual([]);
135
+ });
136
+
137
+ test('options and growth on one volume: the PUT keeps the live size, the resize follows', async () => {
138
+ const pve = cluster();
139
+ seed(pve, NODE, VMID);
140
+ const mp0 = 'tank:subvol-100-disk-0,mp=/data,backup=1,size=300G';
141
+ const run = await lxcEngine(pve).deploy(ProxmoxLxc('ct', pasted({ mp0 })).pipe(adopt(true)));
142
+ expect(run.failure).toBe('');
143
+ const path = `nodes/${NODE}/lxc/${String(VMID)}`;
144
+ expect(writesOf(pve)).toEqual([`PUT ${path}/config`, `PUT ${path}/resize`]);
145
+ const put = pve.seen.find((call) => call.path.endsWith('/config') && call.method === 'PUT');
146
+ expect(put?.form.get('mp0')).toBe('tank:subvol-100-disk-0,mp=/data,backup=1,size=200G');
147
+ expect(pve.guests.get(KEY)?.['mp0']).toContain('size=300G');
148
+ });
149
+
150
+ test('a vmid the cluster spells as a string is still listed, never absent', async () => {
151
+ const pve = cluster();
152
+ pve.others.push({ node: 'pve2', type: 'qemu', vmid: '101' });
153
+ const run = await lxcEngine(pve).plan(
154
+ ProxmoxLxc('ct', pasted({ ostemplate: TEMPLATE, vmid: 101 })),
155
+ );
156
+ expect(run.failure).toContain('vmid 101 is a qemu on pve2');
157
+ });
158
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Alchemy's real `Plan.make` and `apply` over fake-pve-lxc.ts, for the `Proxmox.Lxc` tests — the
3
+ * mesh-node-harness.ts composition, with plan-only runs added because the adoption gate is a PLAN.
4
+ *
5
+ * ⛔ TEST-ONLY. No provider imports this file and it is not on the barrel.
6
+ * ⛔ THE SHELL'S OPENBAO VARIABLES ARE REPLACED FOR THE LIFE OF EACH RUN. `mint` reads
7
+ * `BAO_AGENT_ADDR` before `BAO_ADDR`, and `BAO_TOKEN` rides every mint as a header; a developer
8
+ * shell with either set would otherwise send its real token to the fake, or its mint to a real
9
+ * agent. fake-bao-env.ts clears every BAO_* / VAULT_*, points BAO_ADDR at the fake, and
10
+ * restores them afterwards.
11
+ * ★ WARNINGS ARE CAPTURED, because the drift warning in lxc.ts is the only thing a cold adoption
12
+ * plan says about a declaration that does not match yet (the plan itself reads `adopted`).
13
+ * ★ ONE ARTIFACT STORE ACROSS A PLAN AND ITS APPLY, and `--adopt` as the AdoptPolicy service, as
14
+ * openbao/fake-stack.ts provides them: the ownership rule reads both (src/ownership/).
15
+ */
16
+ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
17
+ import { apply } from 'alchemy/Apply';
18
+ import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
+ import * as Plan from 'alchemy/Plan';
20
+ import { type CompiledStack, make as makeStack } from 'alchemy/Stack';
21
+ import { Stage } from 'alchemy/Stage';
22
+ import * as State from 'alchemy/State';
23
+ import * as Cause from 'effect/Cause';
24
+ import * as Effect from 'effect/Effect';
25
+ import * as Exit from 'effect/Exit';
26
+ import * as Layer from 'effect/Layer';
27
+ import * as Logger from 'effect/Logger';
28
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
29
+ import type { PveTarget } from './credentials.ts';
30
+ import { withFakeBao } from './fake-bao-env.ts';
31
+ import { FAKE_BAO, FAKE_MEMBER, type FakePve } from './fake-pve-lxc.ts';
32
+ import { ProxmoxLxcProvider } from './lxc.ts';
33
+
34
+ export const TARGET: PveTarget = {
35
+ members: [FAKE_MEMBER],
36
+ mount: 'proxmox-lxc-test',
37
+ scheme: 'pve',
38
+ };
39
+
40
+ /**
41
+ * A PRODUCTION-SHAPED config — the same keys and the same spellings pvesh printed, PVE's defaults
42
+ * written out — with every VALUE a placeholder: TEST-NET addresses, RFC 7042 documentation MACs,
43
+ * and a made-up pool, VLAN, mount path, gid and sizes.
44
+ * ⛔ SHAPE ONLY. This file ships in the npm tarball (`files` includes `src`), so nothing here may be
45
+ * the estate's real topology — sizes, VLANs and bridges included, not only addresses.
46
+ */
47
+ export const LIVE: Readonly<Record<string, unknown>> = {
48
+ arch: 'amd64',
49
+ cores: 4,
50
+ dev0: '/dev/dri/renderD128,uid=0,gid=44,mode=0660',
51
+ dev1: '/dev/net/tun,uid=0,gid=0,mode=0666',
52
+ features: 'nesting=1',
53
+ hostname: 'ct-example',
54
+ lxc: [['lxc.prlimit.memlock', 'unlimited']],
55
+ memory: 8192,
56
+ mp0: 'tank:subvol-100-disk-0,mp=/data,backup=0,size=200G',
57
+ nameserver: '192.0.2.254',
58
+ net0:
59
+ 'name=eth0,bridge=vmbr0,firewall=0,gw=192.0.2.1,hwaddr=00:00:5E:00:53:01,' +
60
+ 'ip=192.0.2.10/24,mtu=1500,tag=42,type=veth',
61
+ net1: 'name=eth1,bridge=vmbr1,hwaddr=00:00:5E:00:53:02,ip=198.51.100.4/24,mtu=1500,type=veth',
62
+ onboot: 1,
63
+ ostype: 'debian',
64
+ rootfs: 'local-zfs:subvol-100-disk-0,size=40G',
65
+ searchdomain: 'example.com',
66
+ swap: 0,
67
+ tags: 'web',
68
+ unprivileged: 1,
69
+ };
70
+
71
+ /** Put `config` live on the fake as `node/vmid`, with a digest as PVE would add. */
72
+ export const seed = (fake: FakePve, node: string, vmid: number, config = LIVE) => {
73
+ fake.guests.set(`${node}/${String(vmid)}`, { ...config, digest: 'digest-live' });
74
+ };
75
+
76
+ type Planned = { readonly action: string };
77
+ type PlanView = {
78
+ readonly resources: Readonly<Record<string, Planned>>;
79
+ readonly deletions: Readonly<Record<string, Planned | undefined>>;
80
+ };
81
+
82
+ /** What the CLI would set for one run: `adopt` is `--adopt`. */
83
+ export type RunOptions = { readonly adopt?: boolean };
84
+
85
+ export type Run = {
86
+ /** Planned action per FQN, deletions included; empty when the plan itself failed. */
87
+ readonly actions: Readonly<Record<string, string>>;
88
+ /** The failure sentence, or `''`. */
89
+ readonly failure: string;
90
+ readonly warnings: readonly string[];
91
+ };
92
+
93
+ const withBaoEnv = <A>(body: () => Promise<A>): Promise<A> => withFakeBao(FAKE_BAO, body);
94
+
95
+ export const lxcEngine = (fake: FakePve) => {
96
+ const rows: Record<string, Record<string, Record<string, State.ResourceState>>> = {};
97
+ const state = Layer.succeed(State.State, State.InMemoryService(rows));
98
+ const providers = ProxmoxLxcProvider().pipe(Layer.provideMerge(FetchHttpClient.layer));
99
+ const run = (
100
+ declare: Effect.Effect<unknown, unknown, unknown>,
101
+ write: boolean,
102
+ options: RunOptions,
103
+ ) =>
104
+ withBaoEnv(async (): Promise<Run> => {
105
+ const warnings: string[] = [];
106
+ const collect = Logger.make((options) => {
107
+ if (options.logLevel === 'Warn') warnings.push(String(options.message));
108
+ });
109
+ const exit = await Effect.runPromiseExit(
110
+ declare.pipe(
111
+ makeStack({ name: 'lxc', providers, state }) as never,
112
+ Effect.flatMap((compiled: CompiledStack) =>
113
+ Plan.make(compiled).pipe(
114
+ Effect.tap((planned) => (write ? apply(planned) : Effect.void)),
115
+ provideFreshArtifactStore,
116
+ Effect.provide(compiled.services),
117
+ ),
118
+ ),
119
+ options.adopt === undefined
120
+ ? (e) => e
121
+ : Effect.provideService(AdoptPolicy, options.adopt),
122
+ Effect.provide(Layer.succeed(Stage, 'test')),
123
+ Effect.provide(Logger.layer([collect])),
124
+ Effect.scoped,
125
+ Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
126
+ // ⚠️ THE CAST IS AT THE ENGINE'S TYPED BOUNDARY (fake-stack.ts says why): the stack body
127
+ // is typed against every service a CLI run provides, and this run provides the few it uses.
128
+ ) as unknown as Effect.Effect<PlanView, unknown>,
129
+ );
130
+ const actions: Record<string, string> = {};
131
+ if (Exit.isSuccess(exit)) {
132
+ for (const [fqn, node] of Object.entries(exit.value.resources)) actions[fqn] = node.action;
133
+ for (const [fqn, node] of Object.entries(exit.value.deletions)) {
134
+ if (node !== undefined) actions[fqn] = node.action;
135
+ }
136
+ }
137
+ const failure = Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : '';
138
+ return { actions, failure, warnings };
139
+ });
140
+ return {
141
+ /** Plan only — what `alchemy plan` shows. Nothing is applied and no state is written. */
142
+ plan: (declare: Effect.Effect<unknown, unknown, unknown>, options: RunOptions = {}) =>
143
+ run(declare, false, options),
144
+ /** Plan, then apply. */
145
+ deploy: (declare: Effect.Effect<unknown, unknown, unknown>, options: RunOptions = {}) =>
146
+ run(declare, true, options),
147
+ /** The stored row's status, or undefined when there is none. */
148
+ status: (fqn: string) => rows['lxc']?.['test']?.[fqn]?.status,
149
+ };
150
+ };