@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,210 @@
1
+ /**
2
+ * How a container's config values are spelled on the wire, and when two spellings are one value.
3
+ *
4
+ * ★ EVERY RULE HERE IS READ OFF pve-container 6.1.14 (the PVE 9 line, `src/PVE/LXC/Config.pm` at
5
+ * git.proxmox.com HEAD, 2026-09-21) or off a live guest's config read with
6
+ * `pvesh get /nodes/<node>/lxc/100/config`. A rule nobody measured is a forever-diff waiting to
7
+ * happen: the plan reports an update, the deploy writes the same value back, and the next plan
8
+ * reports it again.
9
+ *
10
+ * ⚠️ PVE PRINTS A PROPERTY STRING IN ITS OWN KEY ORDER AND WITH ITS OWN DEFAULTS FILLED IN.
11
+ * MEASURED on a live guest: `net0` comes back `name=eth0,bridge=…,firewall=0,gw=…,hwaddr=…,ip=…,
12
+ * mtu=1500,tag=…,type=veth` — `name` first, then alphabetical, with `type=veth` and a generated
13
+ * `hwaddr` nobody declared — and `mp0` carries `backup=0` although `0` is what absent means.
14
+ * So values are compared as maps with the defaults dropped, never as strings.
15
+ */
16
+ import { canonicalToken, propertyString } from './values.ts';
17
+
18
+ /** `a=1,b=2` → `[['a','1'],['b','2']]`. A bare token is `defaultKey`'s value. */
19
+ export const pairs = (value: string, defaultKey?: string): [string, string][] =>
20
+ value
21
+ .split(',')
22
+ .map((part) => part.trim())
23
+ .filter((part) => part !== '')
24
+ .map((part): [string, string] => {
25
+ const at = part.indexOf('=');
26
+ if (at === -1) return [defaultKey ?? part, defaultKey === undefined ? '' : part];
27
+ return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
28
+ });
29
+
30
+ /**
31
+ * A key's default, per family: the value PVE treats absence as.
32
+ * ⚠️ `replicate` DEFAULTS ON and everything else here defaults off. `backup` on a mount point is
33
+ * off when absent: vzdump skips any `mpN` without `backup=1` (the rootfs is always included).
34
+ */
35
+ const DEFAULTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
36
+ dev: { 'deny-write': '0' },
37
+ features: { force_rw_sys: '0', fuse: '0', keyctl: '0', mknod: '0', nesting: '0' },
38
+ net: { firewall: '0', 'host-managed': '0', link_down: '0', type: 'veth' },
39
+ volume: { backup: '0', keepattrs: '0', quota: '0', replicate: '1', ro: '0', shared: '0' },
40
+ };
41
+
42
+ /** Boolean keys, whose `1`/`true`/`on` spellings are one value. */
43
+ const FLAGS = new Set([
44
+ 'acl',
45
+ 'backup',
46
+ 'deny-write',
47
+ 'firewall',
48
+ 'force_rw_sys',
49
+ 'fuse',
50
+ 'host-managed',
51
+ 'keepattrs',
52
+ 'keyctl',
53
+ 'link_down',
54
+ 'mknod',
55
+ 'nesting',
56
+ 'quota',
57
+ 'replicate',
58
+ 'ro',
59
+ 'shared',
60
+ ]);
61
+
62
+ /** Keys holding a `;` list PVE does not promise to keep in order. */
63
+ const SETS = new Set(['mount', 'mountoptions']);
64
+
65
+ /** One option value in its comparable spelling. ⚠️ A MAC is upper-cased: PVE stores it that way. */
66
+ const canonicalValue = (key: string, value: string) => {
67
+ if (FLAGS.has(key)) return canonicalToken(value);
68
+ if (SETS.has(key))
69
+ return [...new Set(value.split(';').map((part) => part.trim()))].sort().join(';');
70
+ if (key === 'hwaddr') return value.toUpperCase();
71
+ return value;
72
+ };
73
+
74
+ /** A property string as a map of comparable values, with `family`'s defaults dropped. */
75
+ export const optionMap = (
76
+ value: string,
77
+ family: keyof typeof DEFAULTS,
78
+ defaultKey?: string,
79
+ ): Map<string, string> => {
80
+ const defaults = DEFAULTS[family] ?? {};
81
+ const out = new Map<string, string>();
82
+ for (const [key, raw] of pairs(value, defaultKey)) {
83
+ const canonical = canonicalValue(key, raw);
84
+ if (defaults[key] !== canonical) out.set(key, canonical);
85
+ }
86
+ return out;
87
+ };
88
+
89
+ export const sameMap = (left: Map<string, string>, right: Map<string, string>) =>
90
+ left.size === right.size && [...left].every(([key, value]) => right.get(key) === value);
91
+
92
+ /** A map as one order-free string, for comparing and for naming a value in a message. */
93
+ export const sortedForm = (map: Map<string, string>) =>
94
+ [...map]
95
+ .map(([key, value]) => `${key}=${value}`)
96
+ .sort()
97
+ .join(',');
98
+
99
+ /** A map back to a property string, `lead` keys first — PVE wants `name` and the volume first. */
100
+ export const printMap = (map: Map<string, string>, lead: readonly string[] = []) =>
101
+ [...lead.filter((key) => map.has(key)), ...[...map.keys()].filter((k) => !lead.includes(k))]
102
+ .map((key) => `${key}=${map.get(key) ?? ''}`)
103
+ .join(',');
104
+
105
+ /**
106
+ * `net` maps for comparison. ⚠️ AN UNDECLARED `hwaddr` IS PVE'S, NOT DRIFT: PVE generates one on
107
+ * create, so the live MAC is dropped before comparing when the declaration names none.
108
+ */
109
+ export const netMaps = (declared: string, live: string) => {
110
+ const want = optionMap(declared, 'net');
111
+ const have = optionMap(live, 'net');
112
+ if (!want.has('hwaddr')) have.delete('hwaddr');
113
+ return { have, want };
114
+ };
115
+
116
+ /**
117
+ * The `net` value to PUT: the declaration, plus the live MAC when it names none.
118
+ * ⛔ WITHOUT THE LIVE MAC, A PUT OF AN UNCHANGED NIC WOULD GENERATE A NEW ONE — a new MAC is a new
119
+ * DHCP lease and a new IPv6 link-local address for a guest that only had its MTU edited.
120
+ */
121
+ export const netWrite = (declared: string, live: string | undefined) => {
122
+ const want = new Map(pairs(declared));
123
+ const mac = live === undefined ? undefined : new Map(pairs(live)).get('hwaddr');
124
+ if (!want.has('hwaddr') && mac !== undefined) want.set('hwaddr', mac);
125
+ return printMap(want, ['name']);
126
+ };
127
+
128
+ /** Tags as PVE stores them: split on `;`, `,` or space, deduplicated, sorted, lower-cased. */
129
+ export const tagSet = (value: string) =>
130
+ [...new Set(value.split(/[;,\s]+/).filter((tag) => tag !== ''))]
131
+ .map((tag) => tag.toLowerCase())
132
+ .sort()
133
+ .join(';');
134
+
135
+ /** A resolver list: order matters, the separator does not. */
136
+ export const orderedList = (value: string) =>
137
+ value
138
+ .split(/[;,\s]+/)
139
+ .filter((part) => part !== '')
140
+ .join(' ');
141
+
142
+ /**
143
+ * ⚠️ PVE STORES A DESCRIPTION AS `#` COMMENT LINES AND READS EACH BACK WITH `"\n"` APPENDED
144
+ * (`parse_pct_config`), so `foo` comes back `foo\n`. Trailing whitespace is not the value.
145
+ */
146
+ export const descriptionText = (value: string) => value.replace(/\s+$/, '');
147
+
148
+ /** How each scalar config key is compared. A key missing here is not one this resource manages. */
149
+ export const SCALAR_KINDS: Readonly<Record<string, 'text' | 'number' | 'flag' | 'form'>> = {
150
+ arch: 'text',
151
+ cmode: 'text',
152
+ console: 'flag',
153
+ cores: 'number',
154
+ cpulimit: 'number',
155
+ cpuunits: 'number',
156
+ description: 'form',
157
+ features: 'form',
158
+ hostname: 'text',
159
+ memory: 'number',
160
+ nameserver: 'form',
161
+ onboot: 'flag',
162
+ ostype: 'text',
163
+ protection: 'flag',
164
+ searchdomain: 'form',
165
+ startup: 'form',
166
+ swap: 'number',
167
+ tags: 'form',
168
+ timezone: 'text',
169
+ tty: 'number',
170
+ };
171
+
172
+ /** Keys a declaration may set to `''` to mean "remove it". `delete=` takes them; nothing else may. */
173
+ export const CLEARABLE: ReadonlySet<string> = new Set([
174
+ 'description',
175
+ 'features',
176
+ 'nameserver',
177
+ 'searchdomain',
178
+ 'startup',
179
+ 'tags',
180
+ 'timezone',
181
+ ]);
182
+
183
+ /**
184
+ * What an absent key means, from the confdesc defaults. ⚠️ Only these compare equal to absence;
185
+ * `cores` absent means "every host core", which no number declares.
186
+ */
187
+ export const SCALAR_DEFAULTS: Readonly<Record<string, string>> = {
188
+ arch: 'amd64',
189
+ cmode: 'tty',
190
+ console: '1',
191
+ cpulimit: '0',
192
+ memory: '512',
193
+ onboot: '0',
194
+ protection: '0',
195
+ swap: '512',
196
+ tty: '2',
197
+ };
198
+
199
+ /** One scalar value in its comparable spelling. */
200
+ export const scalarForm = (key: string, value: string): string => {
201
+ const kind = SCALAR_KINDS[key];
202
+ if (kind === 'number') return String(Number(value));
203
+ if (kind === 'flag') return canonicalToken(value);
204
+ if (key === 'description') return descriptionText(value);
205
+ if (key === 'features') return sortedForm(optionMap(value, 'features'));
206
+ if (key === 'nameserver' || key === 'searchdomain') return orderedList(value);
207
+ if (key === 'startup') return propertyString(value, 'order');
208
+ if (key === 'tags') return tagSet(value);
209
+ return value;
210
+ };
@@ -1,56 +1,56 @@
1
1
  /**
2
- * `Proxmox.Lxc` — a container, declared. This is what the estate's provision credential exists for.
2
+ * `Proxmox.Lxc` — a container, declared: adopted as it runs, or created from a template.
3
3
  *
4
- * ★ `hf-provision@pve` HOLDS EXACTLY THE ROLE THIS NEEDS, and nothing more: `LXCProvisioner` on `/`
5
- * grants VM.Allocate, VM.Audit, VM.Config.{CPU,Disk,HWType,Memory,Network,Options},
6
- * VM.PowerMgmt, Datastore.AllocateSpace, Datastore.Audit, SDN.Use, Sys.Audit. Read that list as a
7
- * specification: the credential was scoped to provision containers and to do nothing else.
4
+ * ★ `hf-provision@pve` HOLDS EXACTLY THE ROLE THIS NEEDS: `LXCProvisioner` on `/` grants
5
+ * VM.Allocate, VM.Audit, VM.Config.{CPU,Disk,HWType,Memory,Network,Options}, VM.PowerMgmt,
6
+ * Datastore.AllocateSpace, Datastore.Audit, SDN.Use, Sys.Audit. What that role cannot do — device
7
+ * passthrough, bind mounts, features beyond nesting — PVE keeps for root@pam, and lxc-judge.ts
8
+ * refuses at plan with the `pct set` to run instead.
8
9
  *
9
- * ⛔ `vmid` IS THE PRIMARY KEY AND IT IS CLUSTER-WIDE, not per node. Two resources declaring the
10
- * same vmid on different nodes are the same object, and PVE will refuse the second with
11
- * "already exists". It is required rather than allocated here: letting the provider pick the next
12
- * free id would make the identity of a container depend on the order plans happened to run in.
10
+ * ⛔ NOTHING HERE PLANS A REPLACE, AND NOTHING DESTROYS A GUEST BY DEFAULT. A replace of a guest is
11
+ * a delete of a running machine and its disks, so every change PVE cannot make in place — a new
12
+ * vmid, another template, an unprivileged flip, a storage move, a smaller disk, a move to
13
+ * another node — FAILS the plan with a sentence instead. `defaultRemovalPolicy: 'retain'`:
14
+ * dropping the declaration drops the state row and leaves the guest running; only
15
+ * `RemovalPolicy.destroy()` reaches `destroyGuest`, which deletes only a guest that still
16
+ * matches its last declaration, and PVE still refuses a running or protected one.
17
+ * ⛔ AND NOTHING HERE WRITES A VOLUME IT DID NOT ALLOCATE. A create takes `storage:GiB` only
18
+ * (lxc-create-form.ts), and a deploy that planned a create never writes onto a guest it finds.
13
19
  *
14
- * ⚠️ MOST FIELDS ARE CREATE-TIME. `ostemplate` and `storage` are consumed when the rootfs is built
15
- * and are not readable back afterwards, so they are deliberately NOT compared in `matches` --
16
- * a provider that diffed them would report an update on every plan, forever. What is compared is
17
- * what PVE will actually tell you about a running container.
20
+ * ⚠️ AN ADOPTION ALWAYS PLANS `adopted`, EVEN WHEN IT WOULD WRITE. alchemy 2.0.0-beta.79 turns a
21
+ * cold adoption's diff into an update whatever it answered (Plan.ts, `forceUpdateAfterAdoption`)
22
+ * and prints no property diff for it. So `diff` LOGS the keys a deploy would write, by name
23
+ * (never by value), as a warning — the only place a plan can say the declaration is not the live
24
+ * config yet. A clean adoption logs nothing, and its deploy writes nothing (lxc-judge.ts).
25
+ *
26
+ * ⚠️ POWER STATE IS NOT DECLARED. `onboot` is config; start and stop are an operator's act, as for
27
+ * `Proxmox.Vm`. `start: 1` starts a guest once, after the create that built it, and never again.
18
28
  */
19
29
  import { Resource } from 'alchemy';
30
+ import { isResolved } from 'alchemy/Diff';
31
+ import type { Input } from 'alchemy/Input';
20
32
  import * as Provider from 'alchemy/Provider';
21
33
  import * as Effect from 'effect/Effect';
22
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
23
- import { num } from './values.ts';
24
-
25
- export interface LxcProps extends WithTarget {
26
- /** Which node hosts it. Moving a container between nodes is a migration, not an update. */
27
- node: string;
28
- /** ⛔ CLUSTER-WIDE primary key. Required, never auto-allocated — see the ⛔ above. */
29
- vmid: number;
30
- /** e.g. `local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst`. Create-time only. */
31
- ostemplate: string;
32
- /** Storage for the rootfs, e.g. `local-zfs`. Create-time only. */
33
- storage: string;
34
- hostname?: string;
35
- /** MiB. Mutable. */
36
- memory?: number;
37
- cores?: number;
38
- /** e.g. `name=eth0,bridge=vmbr0,ip=dhcp`. Mutable. */
39
- net0?: string;
40
- /** Start on boot. Mutable. */
41
- onboot?: boolean;
42
- }
34
+ import { type Owner, refuseTakeover } from '../ownership/adopt.ts';
35
+ import { ownedRead } from '../ownership/probe.ts';
36
+ import { provingResumes } from '../ownership/resume.ts';
37
+ import { createRefusals } from './lxc-create-form.ts';
38
+ import { identityRefusals } from './lxc-identity.ts';
39
+ import { judge } from './lxc-judge.ts';
40
+ import {
41
+ LxcRefusedError,
42
+ type LxcWhere,
43
+ attributesOf,
44
+ createGuest,
45
+ destroyGuest,
46
+ readLive,
47
+ updateGuest,
48
+ } from './lxc-lifecycle.ts';
49
+ import type { LxcAttributes, LxcProps } from './lxc-props.ts';
50
+ import type { PveRequirements } from './resource.ts';
51
+ import { text } from './values.ts';
43
52
 
44
- export interface LxcAttributes {
45
- vmid: number;
46
- node: string;
47
- hostname: string;
48
- memory: number;
49
- cores: number;
50
- onboot: boolean;
51
- /** `running` | `stopped`. Reported, never declared — power state is not configuration. */
52
- status: string;
53
- }
53
+ export type { LxcAttributes, LxcProps } from './lxc-props.ts';
54
54
 
55
55
  export interface ProxmoxLxc extends Resource<
56
56
  'Proxmox.Lxc',
@@ -60,58 +60,181 @@ export interface ProxmoxLxc extends Resource<
60
60
  PveRequirements
61
61
  > {}
62
62
 
63
- export const ProxmoxLxc = Resource<ProxmoxLxc>('Proxmox.Lxc');
63
+ /** ★ `retain` by default — a guest's disks cannot be rebuilt from a line. See resource.ts. */
64
+ export const ProxmoxLxc = Resource<ProxmoxLxc>('Proxmox.Lxc', { defaultRemovalPolicy: 'retain' });
65
+
66
+ /** The ownership fields of one call (ownership/adopt.ts), with this resource's attributes. */
67
+ type LxcOwner = Owner & { readonly output: LxcAttributes | undefined };
68
+
69
+ const refused = (reasons: readonly string[]) =>
70
+ Effect.fail(new LxcRefusedError(reasons.join('\n')));
64
71
 
65
- const handlers = pveHandlers<LxcProps, LxcAttributes>({
66
- /**
67
- * ⚠️ READ FROM `config`, NOT FROM THE GUEST LIST. `GET /nodes/{n}/lxc` reports runtime shape
68
- * (status, uptime, current memory), while `/config` reports DECLARED shape. Diffing against
69
- * runtime memory would report an update every time a container ballooned.
70
- */
71
- attributes: (live, props) => ({
72
- cores: num(live['cores'], 1),
73
- hostname: typeof live['hostname'] === 'string' ? live['hostname'] : '',
74
- memory: num(live['memory'], 512),
75
- node: props.node,
76
- onboot: live['onboot'] === 1 || live['onboot'] === true,
77
- status: typeof live['status'] === 'string' ? live['status'] : 'unknown',
78
- vmid: props.vmid,
79
- }),
80
- collection: (props) => `nodes/${props.node}/lxc`,
81
- createForm: (props) => ({
82
- cores: String(props.cores ?? 1),
83
- hostname: props.hostname ?? `ct${String(props.vmid)}`,
84
- memory: String(props.memory ?? 512),
85
- onboot: props.onboot === true ? '1' : '0',
86
- ostemplate: props.ostemplate,
87
- storage: props.storage,
88
- vmid: String(props.vmid),
89
- ...(props.net0 === undefined ? {} : { net0: props.net0 }),
90
- }),
91
- matches: (attributes, props) =>
92
- attributes.hostname === (props.hostname ?? `ct${String(props.vmid)}`) &&
93
- attributes.memory === (props.memory ?? 512) &&
94
- attributes.cores === (props.cores ?? 1) &&
95
- attributes.onboot === (props.onboot === true),
96
- path: (props) => `nodes/${props.node}/lxc/${String(props.vmid)}/config`,
97
- /**
98
- * ⚠️ PVE UPDATES A CONTAINER THROUGH `PUT .../config`, THE SAME PATH IT IS READ FROM, which is
99
- * why `path` ends in `/config` while `collection` does not. Create POSTs to the collection;
100
- * read and update both use the config document.
101
- */
102
- updateForm: (props) => ({
103
- cores: String(props.cores ?? 1),
104
- hostname: props.hostname ?? `ct${String(props.vmid)}`,
105
- memory: String(props.memory ?? 512),
106
- onboot: props.onboot === true ? '1' : '0',
107
- ...(props.net0 === undefined ? {} : { net0: props.net0 }),
108
- }),
72
+ /**
73
+ * ★ A ROW WITH NO ATTRIBUTES IS AN UNFINISHED CREATE. provingResumes (ownership/resume.ts) asks
74
+ * `read` whether the guest there is that create's before this runs, and notes it for the apply
75
+ * that follows, so reconcile can tell our own interrupted create from a guest somebody else put
76
+ * at this vmid.
77
+ */
78
+ const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | undefined) =>
79
+ Effect.gen(function* () {
80
+ const identity = identityRefusals(news, olds, output);
81
+ if (identity.length > 0) return yield* refused(identity);
82
+ if (output === undefined) return undefined;
83
+ if (!isResolved(news)) return undefined;
84
+ const live = yield* readLive(news);
85
+ // ⚠️ `update`, not `create`: state exists and the guest does not. reconcile rebuilds it from
86
+ // `ostemplate`, or refuses when there is none — an adopted guest has no recipe.
87
+ // ⛔ AND THE PLAN SAYS SO. A bare `update` here read as an edit while the deploy would build a
88
+ // NEW guest with empty volumes; an uncreatable one now fails at plan rather than at deploy.
89
+ if (live === undefined) {
90
+ const refusals = createRefusals(news);
91
+ if (refusals.length > 0) return yield* refused(refusals);
92
+ yield* Effect.logWarning(
93
+ `Proxmox.Lxc ${news.node}/${String(news.vmid)}: the cluster lists no guest with this ` +
94
+ 'vmid -- a deploy CREATES it again from ostemplate, with new, empty volumes.',
95
+ );
96
+ return { action: 'update' } as const;
97
+ }
98
+ const change = judge(news, live);
99
+ if (change.refuse.length > 0) return yield* refused(change.refuse);
100
+ const moved = news.node !== output.node;
101
+ if (change.drift.length === 0 && !moved) return { action: 'noop' } as const;
102
+ const what =
103
+ change.drift.length === 0
104
+ ? `the guest is on ${news.node} now, not ${output.node} -- a deploy re-records it, ` +
105
+ 'writing nothing.'
106
+ : `live config differs from the declaration in ${change.drift.join(', ')} -- a ` +
107
+ 'deploy writes these.';
108
+ yield* Effect.logWarning(`Proxmox.Lxc ${news.node}/${String(news.vmid)}: ${what}`);
109
+ return { action: 'update' } as const;
110
+ });
111
+
112
+ /**
113
+ * ⛔ THE SAME JUDGE AS `diff`, RE-RUN AGAINST A FRESH READ, AND AN EMPTY CHANGE WRITES NOTHING.
114
+ * Apply routes `adopted` through reconcile (resource.ts has the Apply.ts lines), so this is what
115
+ * runs on the first deploy after an adoption; for a guest that already matches it is two GETs.
116
+ * ⛔ AND IT READS BACK AND JUDGES AGAIN. PVE answers 200 on writes that did nothing, so a change
117
+ * that did not land is a failure here, not a state row claiming it did. ⚠️ A change PVE parked
118
+ * as pending on a running guest DOES read back (GET config merges `[pending]` unless asked for
119
+ * `current`), so it passes here and takes effect at the guest's next restart.
120
+ * ⛔ A CREATE NEVER WRITES ONTO A GUEST THAT IS ALREADY THERE. `output` undefined means the plan
121
+ * said `create`: its adoption probe saw no guest, or never ran (Plan.ts skips it while any prop
122
+ * is an unresolved Output — a `net0` built from a vnet created in the same deploy). Finding one
123
+ * now, it is first an OWNERSHIP question (docs/ownership.md): `refuseTakeover` lets it through
124
+ * only for this resource's own interrupted create or under `--adopt` / `adopt(true)`, and
125
+ * otherwise refuses and forgets the `creating` row, a matching guest included. Then a CONTENT
126
+ * one: a guest that already matches is recorded with no write; any other refuses even under
127
+ * `--adopt`, until a plan can show the keys a deploy would write onto it.
128
+ */
129
+ const reconcile = (owner: LxcOwner, news: LxcProps) =>
130
+ Effect.gen(function* () {
131
+ const { output } = owner;
132
+ const identity = identityRefusals(news, news, output);
133
+ if (identity.length > 0) return yield* refused(identity);
134
+ const live = yield* readLive(news);
135
+ if (live === undefined) {
136
+ const refusals = createRefusals(news);
137
+ if (refusals.length > 0) return yield* refused(refusals);
138
+ yield* createGuest(news);
139
+ } else {
140
+ if (output === undefined) {
141
+ yield* refuseTakeover(owner, `Proxmox.Lxc CT ${String(news.vmid)} on ${news.node}`);
142
+ }
143
+ const change = judge(news, live);
144
+ if (output === undefined && change.drift.length > 0) {
145
+ return yield* refused([
146
+ `CT ${String(news.vmid)} already exists on ${news.node} and differs in ` +
147
+ `${change.drift.join(', ')}, but this deploy planned a create. A create never writes ` +
148
+ 'onto an existing guest: plan again, so the plan can show it and adopt it.',
149
+ ]);
150
+ }
151
+ if (change.refuse.length > 0) return yield* refused(change.refuse);
152
+ if (change.drift.length > 0) yield* updateGuest(news, change, text(live['digest']));
153
+ }
154
+ const after = yield* readLive(news);
155
+ if (after === undefined) {
156
+ return yield* refused([
157
+ `CT ${String(news.vmid)}: the write returned no error but the guest is still absent.`,
158
+ ]);
159
+ }
160
+ const left = judge(news, after).drift;
161
+ if (left.length > 0) {
162
+ return yield* refused([
163
+ `CT ${String(news.vmid)}: after the write, ${left.join(', ')} still differ from the ` +
164
+ 'declaration. PVE may have normalised the value (tags are lower-cased by default) -- ' +
165
+ 'declare what it stores.',
166
+ ]);
167
+ }
168
+ return attributesOf(news, after);
169
+ });
170
+
171
+ const whereOf = (olds: LxcProps, output: LxcAttributes | undefined): LxcWhere => ({
172
+ node: output?.node ?? olds.node,
173
+ target: olds.target,
174
+ vmid: output?.vmid ?? olds.vmid,
109
175
  });
110
176
 
111
177
  /**
112
- * ⛔ PVE REFUSES TO DELETE A RUNNING CONTAINER, and that refusal is kept — `handlers.delete`
113
- * passes it straight through. Stopping a guest so that a `destroy` can proceed is a decision an
114
- * operator makes, not one a plan makes on their behalf while they are reading the diff.
178
+ * ⛔ NO LIVE GUEST IS ADOPTED WITHOUT `--adopt`, NOT EVEN ONE THAT MATCHES (docs/ownership.md,
179
+ * decided 2026-09-21). With no state this read is Alchemy's adoption probe (Plan.ts), and plain
180
+ * attributes would mean "ours", adopted silently. Matching is not proof: a pasted config or a
181
+ * mistyped vmid reads the same as our own guest, and once state claims it,
182
+ * `RemovalPolicy.destroy()` deletes it and its volumes from its real owner. So it reads
183
+ * `Unowned`, and the plan fails "Cannot adopt" until `adopt(true)` or `--adopt` says so.
184
+ * ★ THE RECOVERY READ STILL FINISHES OUR OWN CREATE. A `creating` row whose deploy died after the
185
+ * POST carries its own instance id; `ownedRead` accepts the guest when the state store records
186
+ * that id AND the guest still matches the row's props — the same judge `diff` uses.
187
+ * ⚠️ UNLIKE `CaddyConfig`, which keeps adopting an identical config because its delete never
188
+ * touches Caddy: a container's delete removes a machine and its disks.
115
189
  */
190
+ const read = (owner: LxcOwner, olds: LxcProps) =>
191
+ Effect.gen(function* () {
192
+ const where = whereOf(olds, owner.output);
193
+ const live = yield* readLive(where);
194
+ if (live === undefined) return undefined;
195
+ const settled = Effect.sync(() => judge(olds, live).drift.length === 0);
196
+ return yield* ownedRead(owner, attributesOf(where, live), settled);
197
+ });
198
+
199
+ /**
200
+ * ⛔ HAND-WRITTEN RATHER THAN `pveHandlers`: a guest reads failures as failures, grows disks
201
+ * through a second endpoint, waits on tasks and refuses what the factory would replace. `list`
202
+ * is empty for the reason it is everywhere here: `GET /nodes/{n}/lxc` lists every guest on the
203
+ * node, and adopting one is a declaration, never a side effect of registering the provider.
204
+ */
205
+ type Asked = { readonly fqn: string; readonly instanceId: string };
206
+
207
+ const handlers = {
208
+ delete: ({ olds, output }: { olds: LxcProps; output: LxcAttributes }) =>
209
+ destroyGuest(whereOf(olds, output), olds),
210
+ diff: ({
211
+ news,
212
+ olds,
213
+ output,
214
+ }: {
215
+ news: Input<LxcProps>;
216
+ olds: LxcProps;
217
+ output: LxcAttributes | undefined;
218
+ }) => diff(news, olds, output),
219
+ list: () => Effect.succeed([]),
220
+ read: ({
221
+ fqn,
222
+ instanceId,
223
+ olds,
224
+ output,
225
+ }: Asked & { olds: LxcProps; output: LxcAttributes | undefined }) =>
226
+ read({ fqn, instanceId, output }, olds),
227
+ reconcile: ({
228
+ fqn,
229
+ instanceId,
230
+ news,
231
+ output,
232
+ }: Asked & { news: LxcProps; output: LxcAttributes | undefined }) =>
233
+ reconcile({ fqn, instanceId, output }, news),
234
+ };
235
+
116
236
  export const ProxmoxLxcProvider = () =>
117
- Provider.effect(ProxmoxLxc, Effect.succeed(ProxmoxLxc.Provider.of(handlers)));
237
+ Provider.effect(
238
+ ProxmoxLxc,
239
+ Effect.succeed(ProxmoxLxc.Provider.of(handlers)).pipe(Effect.map(provingResumes)),
240
+ );
@@ -194,6 +194,8 @@ export const awaitTask = (
194
194
  credential: PveCredential,
195
195
  node: string,
196
196
  upid: string,
197
+ // ★ A CALLER WHOSE TASK IS SLOWER THAN A RELOAD SAYS SO: an LXC create unpacks a template.
198
+ attempts: number = ATTEMPTS,
197
199
  ) =>
198
200
  Effect.gen(function* () {
199
201
  /**
@@ -206,7 +208,7 @@ export const awaitTask = (
206
208
  * and a perfectly good reload would stop the deploy. Raw removes the assumption.
207
209
  */
208
210
  const path = `nodes/${node}/tasks/${upid}/status`;
209
- for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
211
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
210
212
  /**
211
213
  * ⛔ A FAILED POLL IS NOT A FAILED RELOAD, AND CALLING IT ONE WOULD BE THE WORST KIND OF
212
214
  * WRONG. If the member serving the poll is the node being reloaded, `ifreload -a` can drop
@@ -65,6 +65,7 @@ import {
65
65
  } from './pbs-datastore-form.ts';
66
66
  import { guardBackend, guardPath, settle } from './pbs-datastore-guard.ts';
67
67
  import type { PveRequirements } from './resource.ts';
68
+ import { formToSend } from './update-guard.ts';
68
69
 
69
70
  export type { PbsDatastoreAttributes, PbsTarget };
70
71
 
@@ -210,9 +211,9 @@ export const PbsDatastoreProvider = () =>
210
211
  } else {
211
212
  yield* guardPath(live, news);
212
213
  yield* guardBackend(live, news);
213
- // ⚠️ An empty form is not a write — resource.ts skips one for the same reason.
214
- const form = updateForm(news);
215
- if (!matches(live, news) && Object.keys(form).length > 0) {
214
+ // ⚠️ An empty form is not a write, and a match is not one either — update-guard.ts.
215
+ const form = formToSend(matches, live, news, updateForm(news));
216
+ if (form !== undefined) {
216
217
  yield* pve(news.target, 'provision', 'PUT', object(news), form);
217
218
  }
218
219
  }
@@ -22,6 +22,7 @@ import * as Effect from 'effect/Effect';
22
22
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
23
23
  import { pve } from './client.ts';
24
24
  import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
25
+ import { formToSend } from './update-guard.ts';
25
26
 
26
27
  /**
27
28
  * ★ WHY SEVERAL FAMILIES HERE DECLARE `defaultRemovalPolicy: 'retain'`, WRITTEN ONCE.
@@ -150,7 +151,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
150
151
  spec.collection(news),
151
152
  spec.createForm(news),
152
153
  );
153
- } else if (spec.updateForm !== undefined && !spec.matches(live, news)) {
154
+ } else if (spec.updateForm !== undefined) {
154
155
  /**
155
156
  * ⛔ ADOPTING AN OBJECT THAT ALREADY MATCHES MUST NOT WRITE TO IT, AND WITHOUT THE
156
157
  * `matches` GUARD IT DID. Alchemy's `adopted` action is NOT a read: Apply.ts routes it
@@ -171,13 +172,12 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
171
172
  * A field deliberately left OUT of `matches` is one this resource does not manage, so
172
173
  * its drift is not this provider's to repair.
173
174
  *
174
- * ⚠️ AN EMPTY FORM IS ALSO NOT A WRITE. `updateForm` can legitimately answer `{}` — a
175
- * storage declaring only `storage`, `type` and its locator has no mutable field at all
176
- * — and PUTting an empty body is a pointless write at best and a 400 that reads as a
177
- * broken provider at worst.
175
+ * ★ THE PREDICATE LIVES IN update-guard.ts NOW, with the ⚠️ on why an empty form is not a
176
+ * write either, because CephPool and PbsDatastore write their own reconcile and need
177
+ * the same one — CephPool did not have it (see that file).
178
178
  */
179
- const form = spec.updateForm(news);
180
- if (Object.keys(form).length > 0) {
179
+ const form = formToSend(spec.matches, live, news, spec.updateForm(news));
180
+ if (form !== undefined) {
181
181
  yield* pve(news.target, 'provision', 'PUT', spec.path(news), form);
182
182
  }
183
183
  }