@homeflare/alchemy 0.8.0 → 0.10.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 (240) 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 +320 -0
  121. package/dist/verify/cli.js.map +15 -0
  122. package/dist/verify/fake-engine.d.ts +57 -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 +297 -0
  127. package/dist/verify/index.js.map +14 -0
  128. package/dist/verify/report.d.ts +6 -0
  129. package/dist/verify/report.d.ts.map +1 -0
  130. package/dist/verify/rows.d.ts +57 -0
  131. package/dist/verify/rows.d.ts.map +1 -0
  132. package/dist/verify/spy.d.ts +31 -0
  133. package/dist/verify/spy.d.ts.map +1 -0
  134. package/dist/verify/verify.d.ts +43 -0
  135. package/dist/verify/verify.d.ts.map +1 -0
  136. package/docs/adopt-verify.md +108 -0
  137. package/docs/adopted-deploys.md +85 -0
  138. package/docs/credentials.md +15 -0
  139. package/docs/launchd-sudo.md +31 -6
  140. package/docs/launchd.md +7 -3
  141. package/docs/ownership.md +113 -0
  142. package/docs/proxmox-lxc.md +196 -0
  143. package/docs/proxmox.md +12 -0
  144. package/package.json +9 -2
  145. package/src/caddy/adopt-scope.test.ts +1 -1
  146. package/src/caddy/config-lifecycle.ts +1 -1
  147. package/src/caddy/config.ts +2 -1
  148. package/src/launchd/adopt-apply.test.ts +166 -0
  149. package/src/launchd/fake-runner.ts +26 -4
  150. package/src/launchd/host-effect.ts +1 -33
  151. package/src/launchd/host-file-lifecycle.ts +39 -14
  152. package/src/launchd/host-file.ts +13 -4
  153. package/src/launchd/job-lifecycle.ts +22 -14
  154. package/src/launchd/job-preflight.ts +18 -1
  155. package/src/launchd/job.ts +13 -4
  156. package/src/launchd/runner.ts +7 -0
  157. package/src/launchd/sudo-acl.ts +68 -0
  158. package/src/launchd/sudo-chain.test.ts +121 -0
  159. package/src/launchd/sudo-guard.ts +89 -10
  160. package/src/launchd/sudo-modes.test.ts +135 -0
  161. package/src/launchd/sudo-runner.ts +40 -59
  162. package/src/launchd/sudo-said.ts +58 -0
  163. package/src/openbao/README.md +5 -2
  164. package/src/openbao/REPLACE.md +13 -11
  165. package/src/openbao/adopt-core.test.ts +119 -0
  166. package/src/openbao/adopt-families.test.ts +97 -0
  167. package/src/openbao/adopt-holes.test.ts +168 -0
  168. package/src/openbao/adopt-recovery.test.ts +71 -0
  169. package/src/openbao/auth-method-reconcile.ts +15 -3
  170. package/src/openbao/auth-method.ts +15 -6
  171. package/src/openbao/auth-role.ts +14 -4
  172. package/src/openbao/cloudflare-role.ts +17 -4
  173. package/src/openbao/fake-bao.ts +52 -0
  174. package/src/openbao/fake-core.ts +141 -0
  175. package/src/openbao/fake-families.ts +11 -5
  176. package/src/openbao/fake-mounts.ts +74 -0
  177. package/src/openbao/fake-stack.ts +17 -4
  178. package/src/openbao/jwt-config.ts +10 -7
  179. package/src/openbao/jwt-role.ts +10 -7
  180. package/src/openbao/kubernetes-role.ts +13 -7
  181. package/src/openbao/mfa-enforcement.ts +20 -6
  182. package/src/openbao/mfa-totp.ts +22 -7
  183. package/src/openbao/mount-move.test.ts +2 -35
  184. package/src/openbao/mount-reconcile.ts +13 -2
  185. package/src/openbao/mount.ts +15 -6
  186. package/src/openbao/pki-role.ts +15 -4
  187. package/src/openbao/plugin-reconcile.ts +16 -5
  188. package/src/openbao/plugin.ts +17 -6
  189. package/src/openbao/policy.ts +17 -4
  190. package/src/openbao/proxmox-role.ts +13 -4
  191. package/src/openbao/rename-adoption.test.ts +32 -19
  192. package/src/openbao/role-reconcile.ts +23 -0
  193. package/src/openbao/ssh-role.ts +14 -4
  194. package/src/ownership/adopt.ts +122 -0
  195. package/src/ownership/ownership.test.ts +200 -0
  196. package/src/ownership/probe.ts +65 -0
  197. package/src/ownership/resume.ts +118 -0
  198. package/src/ownership/rows.ts +117 -0
  199. package/src/ownership/whole.test.ts +39 -0
  200. package/src/ownership/whole.ts +60 -0
  201. package/src/proxmox/adopt-noop.test.ts +112 -0
  202. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  203. package/src/proxmox/ceph-pool-form.ts +3 -3
  204. package/src/proxmox/ceph-pool.ts +10 -3
  205. package/src/proxmox/fake-bao-env.ts +28 -0
  206. package/src/proxmox/fake-pve-lxc.ts +210 -0
  207. package/src/proxmox/fake-pve.ts +75 -0
  208. package/src/proxmox/index.test.ts +6 -6
  209. package/src/proxmox/index.ts +6 -2
  210. package/src/proxmox/lxc-adopt.test.ts +170 -0
  211. package/src/proxmox/lxc-create-form.ts +76 -0
  212. package/src/proxmox/lxc-create.test.ts +126 -0
  213. package/src/proxmox/lxc-guards.test.ts +158 -0
  214. package/src/proxmox/lxc-harness.ts +150 -0
  215. package/src/proxmox/lxc-identity.ts +43 -0
  216. package/src/proxmox/lxc-judge.test.ts +175 -0
  217. package/src/proxmox/lxc-judge.ts +196 -0
  218. package/src/proxmox/lxc-lifecycle.ts +200 -0
  219. package/src/proxmox/lxc-ownership.test.ts +159 -0
  220. package/src/proxmox/lxc-props.ts +215 -0
  221. package/src/proxmox/lxc-read.test.ts +60 -0
  222. package/src/proxmox/lxc-volume.test.ts +96 -0
  223. package/src/proxmox/lxc-volume.ts +153 -0
  224. package/src/proxmox/lxc-wire.ts +210 -0
  225. package/src/proxmox/lxc.ts +216 -93
  226. package/src/proxmox/network-apply-read.ts +3 -1
  227. package/src/proxmox/pbs-datastore.ts +4 -3
  228. package/src/proxmox/resource.ts +7 -7
  229. package/src/proxmox/update-guard.ts +31 -0
  230. package/src/verify/args.ts +82 -0
  231. package/src/verify/cli.test.ts +86 -0
  232. package/src/verify/cli.ts +49 -0
  233. package/src/verify/fake-engine.ts +158 -0
  234. package/src/verify/index.ts +36 -0
  235. package/src/verify/report.ts +52 -0
  236. package/src/verify/rows.ts +169 -0
  237. package/src/verify/spy.test.ts +141 -0
  238. package/src/verify/spy.ts +149 -0
  239. package/src/verify/verify.test.ts +124 -0
  240. package/src/verify/verify.ts +172 -0
@@ -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
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What makes a `Proxmox.Lxc` a DIFFERENT guest: a new vmid, or another template. Judged by `diff`
3
+ * and `reconcile` in lxc.ts before anything else, and never planned as a replace — see lxc.ts.
4
+ */
5
+ import type { LxcAttributes, LxcProps } from './lxc-props.ts';
6
+
7
+ /** A prop's value if it is already plain data, else undefined (an unresolved Output). */
8
+ const plain = (news: unknown, key: string): unknown => {
9
+ const value: unknown = (news as Record<string, unknown>)[key];
10
+ return typeof value === 'string' || typeof value === 'number' ? value : undefined;
11
+ };
12
+
13
+ /**
14
+ * ⛔ IDENTITY IS JUDGED BEFORE `isResolved(news)`. That guard returns early when ANY prop is still
15
+ * an Output, and the engine then plans `update` — which, for a new vmid, would build a second
16
+ * guest and orphan the first (the openbao rename lesson, rename.ts). A template is identity
17
+ * too: it is consumed at create, so a different one is a different guest.
18
+ * ★ `node` IS NOT JUDGED HERE, BECAUSE A NODE CHANGE CAN BE THE DECLARATION CATCHING UP. HA fails
19
+ * a guest over, or somebody runs `pct migrate`; the guest is on its new node before any line
20
+ * says so. `readLive` at the declared node settles both cases: the guest is there (follow it,
21
+ * and re-record state with a write-free update), or it is elsewhere and the read FAILS naming
22
+ * where — asking to move a guest is a migration, and this resource does not migrate.
23
+ */
24
+ export const identityRefusals = (
25
+ news: unknown,
26
+ olds: LxcProps,
27
+ output: LxcAttributes | undefined,
28
+ ): string[] => {
29
+ const was = output?.vmid ?? olds.vmid;
30
+ const vmid = plain(news, 'vmid');
31
+ const template = plain(news, 'ostemplate');
32
+ const reasons: string[] = [];
33
+ if (vmid !== undefined && vmid !== was) {
34
+ reasons.push(
35
+ `vmid: ${String(was)} -> ${String(vmid)} is a different guest. Declare it as a new ` +
36
+ 'resource; this one is retained when its declaration goes.',
37
+ );
38
+ }
39
+ if (template !== undefined && olds.ostemplate !== undefined && template !== olds.ostemplate) {
40
+ reasons.push('ostemplate: a template is consumed at create; another one is a new guest.');
41
+ }
42
+ return reasons;
43
+ };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The pure half of `Proxmox.Lxc`: which spellings are one value, what a change writes, and what is
3
+ * refused — no server. The engine-level tests beside this one prove the same rules survive plan
4
+ * and apply; these pin each rule on its own, with PVE's reason next to it.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { TARGET } from './lxc-harness.ts';
8
+ import { createForm, createRefusals } from './lxc-create-form.ts';
9
+ import { judge } from './lxc-judge.ts';
10
+ import { type LxcProps, rawKeysOf, storedConfig } from './lxc-props.ts';
11
+ import { judgeVolume, parseVolume, sizeBytes } from './lxc-volume.ts';
12
+
13
+ const props = (over: Partial<LxcProps>): LxcProps => ({
14
+ node: 'pve1',
15
+ target: TARGET,
16
+ vmid: 100,
17
+ ...over,
18
+ });
19
+ const live = (config: Record<string, unknown>) => ({ unprivileged: 1, ...config });
20
+
21
+ describe('sizes and volumes, as pve-container parses them', () => {
22
+ test('parse_size: binary units, bare bytes, and nothing else', () => {
23
+ expect(sizeBytes('40G')).toBe(40 * 1024 ** 3);
24
+ expect(sizeBytes('1.5T')).toBe(1.5 * 1024 ** 4);
25
+ expect(sizeBytes('512M')).toBe(512 * 1024 ** 2);
26
+ expect(sizeBytes('100')).toBe(100);
27
+ expect(sizeBytes('40GB')).toBeUndefined();
28
+ });
29
+
30
+ test('classify_mountpoint, plus the new-disk spelling', () => {
31
+ expect(parseVolume('local-zfs:40').kind).toBe('new');
32
+ expect(parseVolume('local-zfs:subvol-1-disk-0,size=40G').kind).toBe('volume');
33
+ expect(parseVolume('/srv/share,mp=/share').kind).toBe('bind');
34
+ expect(parseVolume('/dev/sdb1,mp=/disk').kind).toBe('device');
35
+ });
36
+
37
+ test('an options change on a volume declared as new is written with the LIVE volume id', () => {
38
+ const verdict = judgeVolume('mp0', 'tank:200,mp=/data,backup=1', LIVE_MP);
39
+ expect(verdict).toEqual({ put: 'tank:subvol-100-disk-0,mp=/data,backup=1,size=200G' });
40
+ });
41
+
42
+ test('a fractional new-disk size grows by the same GiB', () => {
43
+ expect(judgeVolume('mp0', 'tank:200.5,mp=/data', LIVE_MP)).toEqual({ resize: '200.5G' });
44
+ });
45
+ });
46
+
47
+ const LIVE_MP = 'tank:subvol-100-disk-0,mp=/data,backup=0,size=200G';
48
+
49
+ describe('scalars compare the way PVE stores them', () => {
50
+ test.each([
51
+ [
52
+ 'a description PVE handed back with a newline',
53
+ { description: 'hi' },
54
+ { description: 'hi\n' },
55
+ ],
56
+ ['tags in another order and case', { tags: 'B;a' }, { tags: 'a;b' }],
57
+ ['a default PVE leaves out', { onboot: 0, cpulimit: 0, swap: 512 }, {}],
58
+ ['startup with its default key bare', { startup: '1,up=30' }, { startup: 'order=1,up=30' }],
59
+ ['a boolean as true', { protection: true }, { protection: 1 }],
60
+ [
61
+ 'features with an off key spelled out',
62
+ { features: 'nesting=1,keyctl=0' },
63
+ { features: 'nesting=1' },
64
+ ],
65
+ ])('%s is no drift', (_, declared, config) => {
66
+ expect(judge(props(declared as Partial<LxcProps>), live(config)).drift).toEqual([]);
67
+ });
68
+
69
+ test('cores absent means every host core, which no number is', () => {
70
+ expect(judge(props({ cores: 4 }), live({})).put).toEqual({ cores: '4' });
71
+ });
72
+
73
+ test('nameserver order is meaningful', () => {
74
+ const change = judge(props({ nameserver: 'b a' }), live({ nameserver: 'a b' }));
75
+ expect(change.put).toEqual({ nameserver: 'b a' });
76
+ });
77
+
78
+ test("'' clears a clearable key and refuses one that cannot be removed", () => {
79
+ expect(judge(props({ description: '' }), live({ description: 'x\n' })).clear).toEqual([
80
+ 'description',
81
+ ]);
82
+ expect(judge(props({ hostname: '' }), live({ hostname: 'x' })).refuse[0]).toMatch(
83
+ /cannot be removed/,
84
+ );
85
+ });
86
+
87
+ test('a key this resource does not manage is refused, not written', () => {
88
+ const odd = props({}) as unknown as Record<string, unknown>;
89
+ odd['hookscript'] = 'local:snippets/x.sh';
90
+ expect(judge(odd as unknown as LxcProps, live({})).refuse[0]).toMatch(/not a config key/);
91
+ });
92
+ });
93
+
94
+ describe('features follow check_ct_modify_config_perm', () => {
95
+ test('nesting alone on an unprivileged guest is an update', () => {
96
+ expect(judge(props({ features: 'nesting=1' }), live({})).put).toEqual({
97
+ features: 'nesting=1',
98
+ });
99
+ });
100
+
101
+ test('any features write on a privileged guest is root@pam only', () => {
102
+ const change = judge(props({ features: 'nesting=1' }), { unprivileged: 0 });
103
+ expect(change.refuse[0]).toMatch(/root@pam/);
104
+ expect(change.put).toEqual({});
105
+ });
106
+
107
+ test('clearing features that hold more than nesting is root@pam only, as PVE counts keys', () => {
108
+ const change = judge(props({ features: '' }), live({ features: 'nesting=1,keyctl=0' }));
109
+ expect(change.refuse[0]).toMatch(/pct set 100 --delete features/);
110
+ });
111
+ });
112
+
113
+ describe('NICs and devices', () => {
114
+ test("net '' removes a NIC; a new NIC is added as declared", () => {
115
+ const change = judge(props({ net1: '', net2: 'name=eth2,bridge=vmbr1' }), live({ net1: 'x' }));
116
+ expect(change.clear).toEqual(['net1']);
117
+ expect(change.put).toEqual({ net2: 'name=eth2,bridge=vmbr1' });
118
+ });
119
+
120
+ test('a declared MAC is compared, case-insensitively', () => {
121
+ const net = 'name=eth0,bridge=vmbr0,hwaddr=00:00:5E:00:53:01,type=veth';
122
+ expect(judge(props({ net0: net.toLowerCase() }), live({ net0: net })).drift).toEqual([]);
123
+ const other = judge(props({ net0: net.replace(':01', ':09') }), live({ net0: net }));
124
+ expect(other.drift).toEqual(['net0']);
125
+ });
126
+
127
+ test("a device with deny-write=0 is the same device; a new one is root@pam's", () => {
128
+ const dev = '/dev/net/tun,deny-write=0';
129
+ expect(judge(props({ dev0: dev }), live({ dev0: '/dev/net/tun' })).drift).toEqual([]);
130
+ expect(judge(props({ dev0: dev }), live({})).refuse[0]).toMatch(/Device passthrough/);
131
+ });
132
+ });
133
+
134
+ describe('create', () => {
135
+ test('the form carries declared keys, drops empties, and sends features without off keys', () => {
136
+ const form = createForm(
137
+ props({
138
+ description: '',
139
+ features: 'nesting=1,keyctl=0',
140
+ ostemplate: 'local:vztmpl/t.tar.zst',
141
+ start: true,
142
+ unprivileged: true,
143
+ }),
144
+ );
145
+ expect(form).toEqual({
146
+ features: 'nesting=1',
147
+ ostemplate: 'local:vztmpl/t.tar.zst',
148
+ start: '1',
149
+ unprivileged: '1',
150
+ vmid: '100',
151
+ });
152
+ });
153
+
154
+ test('a privileged create with any feature is refused', () => {
155
+ const refusals = createRefusals(
156
+ props({ features: 'nesting=1', ostemplate: 't', unprivileged: false }),
157
+ );
158
+ expect(refusals.join('\n')).toMatch(/Any feature on a privileged guest/);
159
+ });
160
+ });
161
+
162
+ describe('what state keeps', () => {
163
+ test('never the notes, digest, lock, env or raw lxc values; raw keys by name only', () => {
164
+ const read = {
165
+ description: 'notes a human wrote\n',
166
+ digest: 'd',
167
+ env: 'A=1',
168
+ hostname: 'x',
169
+ lock: 'backup',
170
+ lxc: [['lxc.a', 'v']],
171
+ };
172
+ expect(storedConfig(read)).toEqual({ hostname: 'x' });
173
+ expect(rawKeysOf(read)).toEqual(['lxc.a']);
174
+ });
175
+ });