@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,159 @@
1
+ /**
2
+ * Whose guest it is — docs/ownership.md applied to `Proxmox.Lxc`, through the real engine over
3
+ * fake-pve-lxc.ts (placeholders only).
4
+ *
5
+ * ⛔ WHAT THESE PIN: with no state, no live guest is adopted without `--adopt` or `adopt(true)`,
6
+ * not even one that matches the declaration; `adopt(false)` wins over the flag; a deploy that
7
+ * planned a create refuses a guest it then finds, and forgets its `creating` row; under
8
+ * `--adopt` it records a matching one with no write and still refuses one that differs; and a
9
+ * create interrupted after its POST resumes WITHOUT `--adopt` when the guest is what it wrote,
10
+ * and asks for `--adopt` when someone changed it meanwhile.
11
+ * ★ THE INTERRUPTION IS A CREATE TASK THAT ENDS IN AN ERROR AFTER THE FAKE BUILT THE GUEST — to the
12
+ * engine the same as a deploy killed while it waited on the task: a live guest, and a
13
+ * `creating` row with no attributes.
14
+ */
15
+ import { afterEach, describe, expect, test } from 'bun:test';
16
+ import { adopt } from 'alchemy/AdoptPolicy';
17
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
18
+ import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
19
+ import { ProxmoxLxc } from './lxc.ts';
20
+ import type { LxcProps } from './lxc-props.ts';
21
+
22
+ const NODE = 'pve1';
23
+ const VMID = 100;
24
+ const KEY = `${NODE}/${String(VMID)}`;
25
+ const TAKEOVER = 'already exists, and this stack holds no state for it';
26
+
27
+ const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
28
+ const { lxc: _raw, ...config } = LIVE;
29
+ return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
30
+ };
31
+
32
+ let fake: FakePve | undefined;
33
+ afterEach(() => {
34
+ fake?.stop();
35
+ fake = undefined;
36
+ });
37
+ const cluster = () => {
38
+ fake = fakePve();
39
+ return fake;
40
+ };
41
+ const live = () => {
42
+ const pve = cluster();
43
+ seed(pve, NODE, VMID);
44
+ return pve;
45
+ };
46
+
47
+ describe('with no state, a live guest is never adopted silently', () => {
48
+ test.each([
49
+ ['one that matches', 'ct-example'],
50
+ ['one that differs', 'somebody-else'],
51
+ ])('%s: the plan fails "Cannot adopt" and nothing is written', async (_, hostname) => {
52
+ const pve = live();
53
+ const run = await lxcEngine(pve).deploy(
54
+ ProxmoxLxc('ct', { hostname, node: NODE, target: TARGET, vmid: VMID }),
55
+ );
56
+ expect(run.failure).toContain('Cannot adopt');
57
+ expect(writesOf(pve)).toEqual([]);
58
+ });
59
+
60
+ test('--adopt takes a matching guest over with no write; the next plan is noop', async () => {
61
+ const pve = live();
62
+ const stack = lxcEngine(pve);
63
+ const run = await stack.deploy(ProxmoxLxc('ct', pasted()), { adopt: true });
64
+ expect(run).toMatchObject({ actions: { ct: 'adopted' }, failure: '' });
65
+ expect(writesOf(pve)).toEqual([]);
66
+ expect((await stack.plan(ProxmoxLxc('ct', pasted()))).actions).toEqual({ ct: 'noop' });
67
+ });
68
+
69
+ test('adopt(false) wins over --adopt', async () => {
70
+ const pve = live();
71
+ const declared = ProxmoxLxc('ct', pasted()).pipe(adopt(false));
72
+ const run = await lxcEngine(pve).deploy(declared, { adopt: true });
73
+ expect(run.failure).toContain('Cannot adopt');
74
+ expect(writesOf(pve)).toEqual([]);
75
+ });
76
+ });
77
+
78
+ describe('a create never takes over a guest it finds at apply', () => {
79
+ test('one that appears after the plan is refused, matching or not, and its row forgotten', async () => {
80
+ const pve = live();
81
+ pve.vanish.set(KEY, 2);
82
+ const stack = lxcEngine(pve);
83
+ expect((await stack.plan(ProxmoxLxc('ct', pasted()))).actions).toEqual({ ct: 'create' });
84
+ const run = await stack.deploy(ProxmoxLxc('ct', pasted()));
85
+ expect(run.failure).toContain(TAKEOVER);
86
+ expect(writesOf(pve)).toEqual([]);
87
+ expect(stack.status('ct')).toBeUndefined();
88
+ expect((await stack.plan(ProxmoxLxc('ct', pasted()))).failure).toContain('Cannot adopt');
89
+ });
90
+
91
+ test('under --adopt, one that matches is recorded with no write', async () => {
92
+ const pve = live();
93
+ pve.vanish.set(KEY, 1);
94
+ const stack = lxcEngine(pve);
95
+ const run = await stack.deploy(ProxmoxLxc('ct', pasted()), { adopt: true });
96
+ expect(run).toMatchObject({ actions: { ct: 'create' }, failure: '' });
97
+ expect(writesOf(pve)).toEqual([]);
98
+ expect((await stack.plan(ProxmoxLxc('ct', pasted()))).actions).toEqual({ ct: 'noop' });
99
+ });
100
+
101
+ test('under --adopt, one that differs is still refused until a plan can show it', async () => {
102
+ const pve = live();
103
+ pve.vanish.set(KEY, 2);
104
+ const stack = lxcEngine(pve);
105
+ const declared = () => ProxmoxLxc('ct', pasted({ memory: 1024 }));
106
+ expect((await stack.plan(declared())).actions).toEqual({ ct: 'create' });
107
+ const run = await stack.deploy(declared(), { adopt: true });
108
+ expect(run.failure).toContain('already exists on pve1 and differs in memory');
109
+ expect(writesOf(pve)).toEqual([]);
110
+ });
111
+ });
112
+
113
+ describe('an interrupted create', () => {
114
+ const door = (over: Partial<LxcProps> = {}) =>
115
+ ProxmoxLxc('door', {
116
+ hostname: 'door-example',
117
+ node: NODE,
118
+ ostemplate: 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst',
119
+ rootfs: 'local-zfs:8',
120
+ target: TARGET,
121
+ vmid: 150,
122
+ ...over,
123
+ });
124
+ const interrupted = async () => {
125
+ const pve = cluster();
126
+ const stack = lxcEngine(pve);
127
+ pve.task.exit = 'interrupted';
128
+ expect((await stack.deploy(door())).failure).toContain('ended "interrupted"');
129
+ expect(stack.status('door')).toBe('creating');
130
+ pve.task.exit = 'OK';
131
+ return { pve, stack };
132
+ };
133
+
134
+ test('resumes without --adopt when the guest is what it wrote, and writes nothing more', async () => {
135
+ const { pve, stack } = await interrupted();
136
+ expect((await stack.deploy(door())).failure).toBe('');
137
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
138
+ expect((await stack.plan(door())).actions).toEqual({ door: 'noop' });
139
+ });
140
+
141
+ test('resumes when the plan reads no guest and the apply then finds it (diff noted it)', async () => {
142
+ const { pve, stack } = await interrupted();
143
+ pve.vanish.set(`${NODE}/150`, 1);
144
+ expect((await stack.deploy(door())).failure).toBe('');
145
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
146
+ expect(stack.status('door')).toBe('created');
147
+ });
148
+
149
+ test('asks for --adopt when the guest changed meanwhile, and resumes with it', async () => {
150
+ const { pve, stack } = await interrupted();
151
+ const guest = pve.guests.get(`${NODE}/150`) ?? {};
152
+ guest['hostname'] = 'someone-else';
153
+ expect((await stack.deploy(door())).failure).toContain('Cannot resume creating');
154
+ const resumed = await stack.deploy(door(), { adopt: true });
155
+ expect(resumed.failure).toBe('');
156
+ expect(resumed.warnings.join('\n')).toContain('differs from the declaration in hostname');
157
+ expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('door-example');
158
+ });
159
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * What a `Proxmox.Lxc` declaration says, and what its state keeps.
3
+ *
4
+ * ★ THE PROPS ARE PVE'S OWN KEYS, SPELLED THE WAY `pvesh get /nodes/{node}/lxc/{vmid}/config`
5
+ * PRINTS THEM — `mp0`, `net1`, `dev0`, `rootfs` as property strings, `onboot` as `1`. Adopting a
6
+ * live container is then a paste of its config, not a translation of it, and a translation is
7
+ * where an adoption goes wrong: one mistyped key and the first deploy writes it. The generated
8
+ * `NodesNodeLxcVmidConfigGetReturn` (pve-manager 9.2.11 schema) names the same keys.
9
+ *
10
+ * ⛔ AN UNDECLARED KEY IS UNMANAGED: never sent, never compared, never deleted. A guest carries
11
+ * keys PVE fills in on its own (`arch`, `ostype`, `digest`, a NIC's `hwaddr`) and keys somebody
12
+ * set by hand; guessing a default for either would turn an omission into a write on a running
13
+ * guest. To REMOVE a key, declare it empty (`''`) where lxc-judge.ts allows that.
14
+ *
15
+ * ⛔ NO SECRET IS A PROP, AND THE THREE THAT PVE ACCEPTS ARE TYPED `never`. `password` and
16
+ * `ssh-public-keys` are create parameters; `env` (PVE 9 application containers) holds runtime
17
+ * environment that can carry anything. Alchemy stores props unencrypted and this estate's state
18
+ * store is dumped nightly, so declaring one is a compile error rather than a leak found in a
19
+ * backup. A container created here has no root password: reach it with `pct enter` on its node.
20
+ */
21
+ import { SCALAR_KINDS } from './lxc-wire.ts';
22
+ import type { WithTarget } from './resource.ts';
23
+
24
+ /** A PVE boolean as pvesh prints it (`1`) or as TypeScript writes it (`true`). The same value. */
25
+ export type PveFlag = boolean | 0 | 1;
26
+
27
+ export interface LxcProps extends WithTarget {
28
+ /**
29
+ * The node that hosts it. ⛔ This resource never migrates: naming another node plans only when
30
+ * the guest is already there (after HA or `pct migrate`), and then writes nothing.
31
+ */
32
+ node: string;
33
+ /** ⛔ CLUSTER-WIDE primary key, shared with QEMU VMs. Required, never allocated here. */
34
+ vmid: number;
35
+ /**
36
+ * `local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst`. ⚠️ CREATE-ONLY AND NOT READABLE BACK:
37
+ * PVE consumes it while unpacking the rootfs. Optional, because an adopted guest was built from a
38
+ * template nobody recorded; required only when this resource has to create the guest.
39
+ */
40
+ ostemplate?: string;
41
+ /** Start the guest once, after its create finishes (PVE's create `start`). ⚠️ Never compared. */
42
+ start?: PveFlag;
43
+ /**
44
+ * ⛔ READ-ONLY AFTER CREATE. PVE refuses to change it ("unable to modify read-only option"), so a
45
+ * differing value is refused at plan. Absent from a live config means `0`: privileged. On create,
46
+ * PVE's own default is `1`.
47
+ */
48
+ unprivileged?: PveFlag;
49
+ hostname?: string;
50
+ arch?: string;
51
+ ostype?: string;
52
+ cores?: number;
53
+ /** `0` means no limit. May be fractional (`1.5`). */
54
+ cpulimit?: number;
55
+ cpuunits?: number;
56
+ /** MiB. */
57
+ memory?: number;
58
+ /** MiB. */
59
+ swap?: number;
60
+ /**
61
+ * `local-zfs:subvol-100-disk-0,size=40G` as read, or `local-zfs:40` (40 GiB, new volume) to
62
+ * create. See lxc-volume.ts for what may change: options in place, size up only.
63
+ */
64
+ rootfs?: string;
65
+ /** `nesting=1,keyctl=1`. ⛔ Only `nesting` is writable by an API token — see lxc-judge.ts. */
66
+ features?: string;
67
+ onboot?: PveFlag;
68
+ /** `order=1,up=30,down=60`. */
69
+ startup?: string;
70
+ protection?: PveFlag;
71
+ /** Free text. ⚠️ PVE stores it as `#` comment lines and hands it back with a trailing newline. */
72
+ description?: string;
73
+ /** `a;b`. ⚠️ Compared as a set; PVE sorts and, by default, lowercases them. */
74
+ tags?: string;
75
+ /** Space-separated. Order is meaningful to a resolver, so it is compared in order. */
76
+ nameserver?: string;
77
+ searchdomain?: string;
78
+ timezone?: string;
79
+ console?: PveFlag;
80
+ tty?: number;
81
+ cmode?: string;
82
+ /** `tank:subvol-100-disk-1,mp=/data,backup=1,size=100G`, or `tank:100,mp=/data` to create. */
83
+ [mount: `mp${number}`]: string | undefined;
84
+ /** `name=eth0,bridge=vmbr0,ip=dhcp`. ⚠️ An undeclared `hwaddr` keeps the live MAC. */
85
+ [nic: `net${number}`]: string | undefined;
86
+ /** `/dev/net/tun`, `/dev/dri/renderD128,gid=44,mode=0660`. ⛔ root@pam only — see lxc-judge.ts. */
87
+ [device: `dev${number}`]: string | undefined;
88
+ /** ⛔ A secret. See the header. */
89
+ password?: never;
90
+ /** ⛔ Treated as a secret here. See the header. */
91
+ 'ssh-public-keys'?: never;
92
+ /** ⛔ Can carry anything, and a read token cannot even see it. See the header. */
93
+ env?: never;
94
+ }
95
+
96
+ export interface LxcAttributes {
97
+ node: string;
98
+ vmid: number;
99
+ /**
100
+ * The live config, each value as PVE's own string: every key this resource MANAGES
101
+ * (`isManagedKey`), declared or not, minus the keys in `UNSTORED`.
102
+ * ★ A MAP RATHER THAN TYPED FIELDS so a plan can show whatever managed key the guest carries.
103
+ * ⛔ AN ALLOWLIST, NOT "EVERYTHING BUT A DENYLIST" — see `storedConfig`.
104
+ */
105
+ config: Record<string, string>;
106
+ /**
107
+ * The raw `lxc.*` lines in the guest's config file — NAMES ONLY.
108
+ * ⚠️ A GAP, NOT A FEATURE: PVE's API returns these but cannot set them (`lxc.prlimit.memlock`,
109
+ * `lxc.cgroup2.devices.allow`, `lxc.mount.entry`). They are edited in `/etc/pve/lxc/<vmid>.conf`
110
+ * on the node, so this resource reports that they exist and never compares them. ⛔ Values are
111
+ * not stored: `lxc.environment` is one of the keys PVE allows there.
112
+ */
113
+ rawKeys: string[];
114
+ }
115
+
116
+ /**
117
+ * Keys a read returns that state never keeps.
118
+ * ⚠️ `digest` covers the whole file and `lock` is transient (a running backup), so either would
119
+ * rewrite state on every read; `env` and `lxc` are the secret risks in the header and above.
120
+ * ⛔ `description` IS THE GUEST'S "NOTES" PANEL IN THE PVE UI, where people paste credentials. A
121
+ * declared one is already in props; an undeclared one is not this resource's to copy into an
122
+ * unencrypted state store.
123
+ */
124
+ export const UNSTORED: ReadonlySet<string> = new Set([
125
+ 'description',
126
+ 'digest',
127
+ 'env',
128
+ 'lock',
129
+ 'lxc',
130
+ 'pending',
131
+ ]);
132
+
133
+ /** The indexed families, with PVE's own limits (pve-container `$MAX_*`, 6.1.14). */
134
+ export const INDEXED = {
135
+ dev: 256,
136
+ mp: 256,
137
+ net: 32,
138
+ } as const;
139
+
140
+ export type IndexedFamily = keyof typeof INDEXED;
141
+
142
+ /** `mp3` → `['mp', 3]`; anything else → undefined. ⚠️ `mp03` is not a PVE key. */
143
+ export const indexedKey = (key: string): readonly [IndexedFamily, number] | undefined => {
144
+ const match = /^(mp|net|dev)(0|[1-9]\d*)$/.exec(key);
145
+ if (match === null) return undefined;
146
+ const family = match[1] as IndexedFamily;
147
+ const index = Number(match[2]);
148
+ return index < INDEXED[family] ? [family, index] : undefined;
149
+ };
150
+
151
+ /** Props that are not config keys: where the guest lives, and create-time instructions. */
152
+ export const NOT_CONFIG: ReadonlySet<string> = new Set([
153
+ 'node',
154
+ 'ostemplate',
155
+ 'start',
156
+ 'target',
157
+ 'vmid',
158
+ ]);
159
+
160
+ /**
161
+ * A declaration read by key. ⚠️ The cast is the one place the three template-literal index
162
+ * signatures meet a plain string key; every reader goes through here rather than casting again.
163
+ */
164
+ export const declaredValue = (props: LxcProps, key: string): unknown =>
165
+ (props as unknown as Record<string, unknown>)[key];
166
+
167
+ /** The declared config keys with a value, in a stable order. `''` counts: it means "absent". */
168
+ export const declaredKeys = (props: LxcProps): string[] =>
169
+ Object.keys(props)
170
+ .filter((key) => !NOT_CONFIG.has(key) && declaredValue(props, key) !== undefined)
171
+ .sort();
172
+
173
+ /** One declared value as PVE's wire string. Booleans go as `1`/`0`, numbers as their decimal. */
174
+ export const wireValue = (value: unknown): string =>
175
+ value === true ? '1' : value === false ? '0' : String(value);
176
+
177
+ /**
178
+ * A key this resource compares and may write: a scalar lxc-wire.ts knows, `unprivileged`, `rootfs`,
179
+ * or an indexed `mpN`/`netN`/`devN`. ★ ONE PREDICATE FOR THE THREE PLACES THAT ASK: `judge` refuses
180
+ * any other declared key, `createRefusals` refuses it before a POST, and `storedConfig` keeps
181
+ * nothing else. ⛔ Without the create half, a key smuggled past the types (a cast, a JS caller:
182
+ * `password`, `force`, `restore`, `hookscript`) went into the create body verbatim.
183
+ */
184
+ export const isManagedKey = (key: string): boolean =>
185
+ SCALAR_KINDS[key] !== undefined ||
186
+ key === 'unprivileged' ||
187
+ key === 'rootfs' ||
188
+ indexedKey(key) !== undefined;
189
+
190
+ /**
191
+ * The live config as strings: managed keys only, the unstored ones dropped.
192
+ * ⛔ AN ALLOWLIST, BECAUSE A DENYLIST IS SURPRISED BY THE NEXT PVE RELEASE. `env` joined the config
193
+ * in PVE 9 with application containers, and `entrypoint` with it — a command line, which can
194
+ * carry a token as an argument. Under "store everything except UNSTORED" each new key lands in
195
+ * the unencrypted state store until somebody notices; under this, it is not stored until this
196
+ * resource manages it. `hookscript`, `unusedN`, `parent` and `lock` are dropped the same way.
197
+ */
198
+ export const storedConfig = (live: Record<string, unknown>): Record<string, string> => {
199
+ const out: Record<string, string> = {};
200
+ for (const [key, value] of Object.entries(live)) {
201
+ if (!isManagedKey(key) || UNSTORED.has(key)) continue;
202
+ if (value === null || value === undefined || typeof value === 'object') continue;
203
+ out[key] = wireValue(value);
204
+ }
205
+ return out;
206
+ };
207
+
208
+ /** `[["lxc.prlimit.memlock","unlimited"]]` → `['lxc.prlimit.memlock']`. Values are dropped. */
209
+ export const rawKeysOf = (live: Record<string, unknown>): string[] => {
210
+ const raw = live['lxc'];
211
+ if (!Array.isArray(raw)) return [];
212
+ return raw
213
+ .map((pair: unknown) => (Array.isArray(pair) ? String(pair[0]) : ''))
214
+ .filter((key) => key !== '');
215
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * When a container read counts as "absent" — the question that decides between adopt and create.
3
+ *
4
+ * ⛔ WHAT THESE PIN: only a 500 whose vmid the cluster does not list anywhere is absent, whatever
5
+ * the message says; a 403 or a 500 for a guest the cluster lists on the declared node FAILS the
6
+ * plan with PVE's own error; nothing is written in any case. resource.ts's fold of every error
7
+ * into "absent" is exactly what this resource must not inherit — under it, a lease without
8
+ * VM.Audit plans a CREATE over a running guest.
9
+ */
10
+ import { afterEach, describe, expect, test } from 'bun:test';
11
+ import { adopt } from 'alchemy/AdoptPolicy';
12
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
13
+ import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
14
+ import { ProxmoxLxc } from './lxc.ts';
15
+
16
+ const NODE = 'pve1';
17
+
18
+ let fake: FakePve | undefined;
19
+ afterEach(() => {
20
+ fake?.stop();
21
+ fake = undefined;
22
+ });
23
+
24
+ const declared = (vmid: number) =>
25
+ ProxmoxLxc('ct', {
26
+ hostname: 'ct-example',
27
+ node: NODE,
28
+ ostemplate: 'local:vztmpl/t.tar.zst',
29
+ target: TARGET,
30
+ vmid,
31
+ }).pipe(adopt(true));
32
+
33
+ describe('absent means the cluster does not list the vmid', () => {
34
+ test('a 500 with no message, for a vmid nowhere in the cluster, plans a create', async () => {
35
+ fake = fakePve();
36
+ fake.configErrors.set(`${NODE}/150`, { body: { data: null }, status: 500 });
37
+ const run = await lxcEngine(fake).plan(declared(150));
38
+ expect(run).toMatchObject({ actions: { ct: 'create' }, failure: '' });
39
+ });
40
+
41
+ test('a 500 for a guest the cluster lists on this node fails with PVE’s error', async () => {
42
+ fake = fakePve();
43
+ seed(fake, NODE, 100, LIVE);
44
+ const message = 'got timeout\n';
45
+ fake.configErrors.set(`${NODE}/100`, { body: { data: null, message }, status: 500 });
46
+ const run = await lxcEngine(fake).plan(declared(100));
47
+ expect(run.failure).toContain('got timeout');
48
+ expect(run.actions).toEqual({});
49
+ expect(writesOf(fake)).toEqual([]);
50
+ });
51
+
52
+ test('a 403 is never absent: the plan fails instead of planning a create', async () => {
53
+ fake = fakePve();
54
+ const message = 'Permission check failed (/vms/150, VM.Audit)\n';
55
+ fake.configErrors.set(`${NODE}/150`, { body: { data: null, message }, status: 403 });
56
+ const run = await lxcEngine(fake).plan(declared(150));
57
+ expect(run.failure).toContain('-> 403');
58
+ expect(run.actions).toEqual({});
59
+ });
60
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The mount-point and key-set rules an adversarial review found unpinned (2026-09-21): each test
3
+ * here failed against a mutant of the rule it names.
4
+ *
5
+ * ⛔ WHAT THESE PIN: another volume on the same storage is refused (a PUT of it would detach the
6
+ * live one to unusedN); a bind or device mount is root@pam's whether new or changed; `replicate`
7
+ * defaults ON; an options change that also grows writes the LIVE size; a key the resource does
8
+ * not manage never reaches a create body; and state keeps managed keys only.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { TARGET } from './lxc-harness.ts';
12
+ import { createRefusals } from './lxc-create-form.ts';
13
+ import { judge } from './lxc-judge.ts';
14
+ import { type LxcProps, storedConfig } from './lxc-props.ts';
15
+ import { judgeVolume } from './lxc-volume.ts';
16
+
17
+ const LIVE_MP = 'tank:subvol-100-disk-0,mp=/data,size=200G';
18
+ const props = (over: Record<string, unknown>): LxcProps =>
19
+ ({ node: 'pve1', target: TARGET, vmid: 100, ...over }) as unknown as LxcProps;
20
+ const live = (config: Record<string, unknown>) => ({ unprivileged: 1, ...config });
21
+
22
+ describe('mount points', () => {
23
+ test('another volume on the same storage is refused, never written', () => {
24
+ const verdict = judgeVolume('mp0', 'tank:subvol-100-disk-7,mp=/data,size=200G', LIVE_MP);
25
+ expect(verdict.refuse).toMatch(/detach the live volume to unusedN/);
26
+ expect(verdict.put).toBeUndefined();
27
+ const change = judge(props({ mp0: 'tank:subvol-100-disk-7,mp=/data' }), live({ mp0: LIVE_MP }));
28
+ expect(change.put).toEqual({});
29
+ expect(change.refuse[0]).toMatch(/unusedN/);
30
+ });
31
+
32
+ test('a bind mount is root@pam only, new or changed', () => {
33
+ const added = judge(props({ mp1: '/srv/share,mp=/share' }), live({}));
34
+ expect(added.put).toEqual({});
35
+ expect(added.refuse[0]).toMatch(/pct set 100 --mp1/);
36
+ const bind = '/srv/share,mp=/share';
37
+ expect(judgeVolume('mp1', `${bind},ro=1`, bind)).toEqual({ rootOnly: true });
38
+ expect(judgeVolume('mp1', bind, bind)).toEqual({});
39
+ });
40
+
41
+ test('replicate defaults ON: an explicit replicate=0 is a value, replicate=1 is not', () => {
42
+ expect(judgeVolume('mp0', `${LIVE_MP},replicate=1`, LIVE_MP)).toEqual({});
43
+ const off = judgeVolume('mp0', LIVE_MP, `${LIVE_MP},replicate=0`);
44
+ expect(off.put).toBe('tank:subvol-100-disk-0,mp=/data,size=200G');
45
+ });
46
+
47
+ test('options and growth together: the PUT carries the live size, the resize the new one', () => {
48
+ const verdict = judgeVolume('mp0', 'tank:300,mp=/data,backup=1', LIVE_MP);
49
+ expect(verdict).toEqual({
50
+ put: 'tank:subvol-100-disk-0,mp=/data,backup=1,size=200G',
51
+ resize: '300G',
52
+ });
53
+ });
54
+ });
55
+
56
+ describe('a create allocates new volumes only', () => {
57
+ test('an existing volume id in a create is refused; the new-disk spelling is not', () => {
58
+ const base = { ostemplate: 'local:vztmpl/t.tar.zst' };
59
+ const existing = createRefusals(
60
+ props({ ...base, rootfs: 'local-zfs:subvol-100-disk-0,size=8G' }),
61
+ );
62
+ expect(existing.join('\n')).toMatch(/rootfs: .* names an existing volume/);
63
+ expect(
64
+ createRefusals(props({ ...base, mp0: 'tank:8,mp=/data', rootfs: 'local-zfs:8' })),
65
+ ).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe('keys the resource does not manage', () => {
70
+ test.each([['password'], ['force'], ['restore'], ['hookscript']])(
71
+ '%s is refused at create, so it is never POSTed',
72
+ (key) => {
73
+ const refusals = createRefusals(props({ [key]: '1', ostemplate: 'local:vztmpl/t.tar.zst' }));
74
+ expect(refusals.join('\n')).toContain(`${key}: not a config key this resource manages`);
75
+ },
76
+ );
77
+
78
+ test('state keeps managed keys only: a new PVE key is not stored until it is managed', () => {
79
+ const read = {
80
+ entrypoint: '/usr/bin/app --token=placeholder',
81
+ hookscript: 'local:snippets/x.sh',
82
+ hostname: 'x',
83
+ memory: 512,
84
+ mp0: LIVE_MP,
85
+ parent: 'snap1',
86
+ unprivileged: 1,
87
+ unused0: 'tank:subvol-100-disk-9',
88
+ };
89
+ expect(storedConfig(read)).toEqual({
90
+ hostname: 'x',
91
+ memory: '512',
92
+ mp0: LIVE_MP,
93
+ unprivileged: '1',
94
+ });
95
+ });
96
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `rootfs` and `mpN`: what a declared mount point may change on a live guest, and how.
3
+ *
4
+ * ★ FOUR ANSWERS, AND ONLY ONE OF THEM IS A CONFIG WRITE. Options (`mp=`, `backup`, `acl`, …)
5
+ * change in place with `PUT …/config`. A size INCREASE goes through `PUT …/resize`. Everything
6
+ * else is refused with a sentence, because the only way PVE offers to get there loses the guest
7
+ * or its data:
8
+ * - a size DECREASE — `resize_vm` dies "unable to shrink disk size" (pve-container 6.1.14);
9
+ * - a storage MOVE — that is `pct move-volume`, a copy, not a config edit;
10
+ * - a DIFFERENT volume on the same storage — writing it would detach the live one to `unusedN`;
11
+ * - a bind or device mount — `check_ct_modify_config_perm` lets only `root@pam` write one, and an
12
+ * API token's user is `user@realm!token`, never `root@pam` (pve-access-control `verify_token`).
13
+ *
14
+ * ⛔ AN EXISTING VOLUME IS NEVER WRITTEN WITH THE NEW-DISK SPELLING. `tank:100` in a PUT means
15
+ * "allocate a fresh 100 GiB volume", and PVE then moves the volume the guest was using to
16
+ * `unusedN`. So a declared `tank:100,mp=/data` that matches a live
17
+ * `tank:subvol-100-disk-1,mp=/data,size=100G` compares EQUAL, and an options change on it is
18
+ * written with the LIVE volume id — the declaration's spelling is never sent back for a volume
19
+ * that exists.
20
+ */
21
+ import { optionMap, pairs, sameMap } from './lxc-wire.ts';
22
+
23
+ /** PVE's `$NEW_DISK_RE`: `storage:SIZE`, SIZE in GiB, fractional allowed. */
24
+ const NEW_DISK = /^([^:\s]+):(\d+(?:\.\d+)?)$/;
25
+
26
+ const UNITS: Readonly<Record<string, number>> = {
27
+ G: 1024 ** 3,
28
+ K: 1024,
29
+ M: 1024 ** 2,
30
+ T: 1024 ** 4,
31
+ };
32
+
33
+ /** PVE's `parse_size`: `\d+(\.\d+)?[KMGT]?`, binary units, bare means bytes. */
34
+ export const sizeBytes = (text: string): number | undefined => {
35
+ const match = /^(\d+(?:\.\d+)?)([KMGT])?$/.exec(text.trim());
36
+ if (match === null) return undefined;
37
+ return Math.trunc(Number(match[1]) * (match[2] === undefined ? 1 : (UNITS[match[2]] ?? 1)));
38
+ };
39
+
40
+ export type Volume = {
41
+ readonly kind: 'new' | 'volume' | 'bind' | 'device';
42
+ /** `new`/`volume`: the storage id. `bind`/`device`: the host path. */
43
+ readonly source: string;
44
+ /** `volume` only: the name after `storage:`. */
45
+ readonly volname: string;
46
+ /** From `size=`, or the new-disk GiB. Undefined when neither says. */
47
+ readonly size: number | undefined;
48
+ /** The size as written, for a resize request. */
49
+ readonly sizeText: string | undefined;
50
+ /** Every other option, comparable. */
51
+ readonly options: Map<string, string>;
52
+ };
53
+
54
+ /** PVE's `classify_mountpoint`, plus the new-disk spelling a declaration may use. */
55
+ export const parseVolume = (text: string): Volume => {
56
+ const [first] = pairs(text, 'volume');
57
+ const spec = first?.[0] === 'volume' ? first[1] : '';
58
+ const options = optionMap(text, 'volume', 'volume');
59
+ options.delete('volume');
60
+ const sizeText = options.get('size');
61
+ options.delete('size');
62
+ const fresh = NEW_DISK.exec(spec);
63
+ if (fresh !== null) {
64
+ const gib = `${fresh[2] ?? '0'}G`;
65
+ return {
66
+ kind: 'new',
67
+ options,
68
+ size: sizeBytes(gib),
69
+ sizeText: gib,
70
+ source: fresh[1] ?? '',
71
+ volname: '',
72
+ };
73
+ }
74
+ const size = sizeText === undefined ? undefined : sizeBytes(sizeText);
75
+ if (spec.startsWith('/')) {
76
+ const kind = spec.startsWith('/dev/') ? 'device' : 'bind';
77
+ return { kind, options, size, sizeText, source: spec, volname: '' };
78
+ }
79
+ const at = spec.indexOf(':');
80
+ return {
81
+ kind: 'volume',
82
+ options,
83
+ size,
84
+ sizeText,
85
+ source: at === -1 ? spec : spec.slice(0, at),
86
+ volname: at === -1 ? '' : spec.slice(at + 1),
87
+ };
88
+ };
89
+
90
+ /** What one mount point needs. At most one of `refuse` / `rootOnly` is set, and then nothing else. */
91
+ export type VolumeVerdict = {
92
+ readonly refuse?: string;
93
+ readonly rootOnly?: boolean;
94
+ /** The value to PUT, built on the LIVE volume id. */
95
+ readonly put?: string;
96
+ /** An absolute size to hand `PUT …/resize`, which only ever grows. */
97
+ readonly resize?: string;
98
+ };
99
+
100
+ export const isHostPath = (volume: Volume) => volume.kind === 'bind' || volume.kind === 'device';
101
+
102
+ /**
103
+ * The value to PUT for an options change: live volume, declared options, LIVE size.
104
+ * ⚠️ THE LIVE SIZE, NOT THE DECLARED ONE: `size=` is written by the resize call itself, and a PUT
105
+ * that sent the declared size ahead of the resize would record a size the volume does not have.
106
+ */
107
+ const rewrite = (live: Volume, declared: Volume) => {
108
+ const parts = [`${live.source}:${live.volname}`];
109
+ for (const [key, value] of declared.options) parts.push(`${key}=${value}`);
110
+ if (live.sizeText !== undefined) parts.push(`size=${live.sizeText}`);
111
+ return parts.join(',');
112
+ };
113
+
114
+ export const judgeVolume = (key: string, declared: string, live: string): VolumeVerdict => {
115
+ const want = parseVolume(declared);
116
+ const have = parseVolume(live);
117
+ if (isHostPath(want) || isHostPath(have)) {
118
+ const same =
119
+ want.kind === have.kind && want.source === have.source && sameMap(want.options, have.options);
120
+ return same ? {} : { rootOnly: true };
121
+ }
122
+ if (want.source !== have.source) {
123
+ return {
124
+ refuse:
125
+ `${key}: the volume is on ${have.source}, declared on ${want.source}. Moving it is ` +
126
+ '`pct move-volume` (a copy), not a config update -- move it by hand, then declare it.',
127
+ };
128
+ }
129
+ if (want.kind === 'volume' && want.volname !== have.volname) {
130
+ return {
131
+ refuse:
132
+ `${key}: declared volume ${want.volname} is not the live ${have.volname}. Writing it would ` +
133
+ 'detach the live volume to unusedN; attach or swap volumes by hand.',
134
+ };
135
+ }
136
+ let resize: string | undefined;
137
+ if (want.size !== undefined && have.size !== undefined && want.size !== have.size) {
138
+ if (want.size < have.size) {
139
+ return {
140
+ refuse:
141
+ `${key}: declared ${want.sizeText ?? '?'} is smaller than the live ` +
142
+ `${have.sizeText ?? '?'}. PVE cannot shrink a container volume ("unable to shrink disk ` +
143
+ 'size"), and replacing the guest to get there is not an update this resource makes.',
144
+ };
145
+ }
146
+ resize = want.sizeText;
147
+ }
148
+ const put = sameMap(want.options, have.options) ? undefined : rewrite(have, want);
149
+ return {
150
+ ...(put === undefined ? {} : { put }),
151
+ ...(resize === undefined ? {} : { resize }),
152
+ };
153
+ };