@homeflare/alchemy 0.11.0 → 0.13.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 (290) hide show
  1. package/README.md +3 -3
  2. package/dist/caddy/admin-guard.d.ts +1 -1
  3. package/dist/caddy/index.js.map +2 -2
  4. package/dist/cloudflare/client.d.ts +1 -1
  5. package/dist/cloudflare/index.js.map +2 -2
  6. package/dist/cloudflare/lock-rules.d.ts +1 -1
  7. package/dist/forgejo/branch-protection-form.d.ts +1 -1
  8. package/dist/forgejo/branch-protection.d.ts +1 -1
  9. package/dist/forgejo/index.js.map +10 -10
  10. package/dist/forgejo/org-actions-secrets.d.ts +1 -1
  11. package/dist/forgejo/org-label.d.ts +1 -1
  12. package/dist/forgejo/org-team.d.ts +1 -1
  13. package/dist/forgejo/repo-webhook.d.ts +1 -1
  14. package/dist/forgejo/repository.d.ts +2 -2
  15. package/dist/forgejo/repository.d.ts.map +1 -1
  16. package/dist/forgejo/resource.d.ts +2 -2
  17. package/dist/forgejo/team-member.d.ts +1 -1
  18. package/dist/openbao/auth-role.d.ts +1 -1
  19. package/dist/openbao/bao-http.d.ts +4 -4
  20. package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
  21. package/dist/openbao/cloudflare-role-form.d.ts +2 -2
  22. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
  23. package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
  24. package/dist/openbao/digest.d.ts +1 -1
  25. package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
  26. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
  27. package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
  28. package/dist/openbao/index.js.map +14 -14
  29. package/dist/openbao/mount.d.ts +1 -1
  30. package/dist/openbao/pki-role.d.ts +1 -1
  31. package/dist/openbao/policy.d.ts +2 -2
  32. package/dist/openbao/proxmox-role-form.d.ts +2 -2
  33. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  34. package/dist/openbao/proxmox-role.d.ts +10 -10
  35. package/dist/openbao/ssh-role.d.ts +3 -3
  36. package/dist/proxmox/acl.d.ts +2 -2
  37. package/dist/proxmox/acl.d.ts.map +1 -1
  38. package/dist/proxmox/alertmanager-body.d.ts +54 -0
  39. package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
  40. package/dist/proxmox/api-token-form.d.ts +2 -2
  41. package/dist/proxmox/api-token.d.ts +11 -11
  42. package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
  43. package/dist/proxmox/ceph-daemon.d.ts +19 -19
  44. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  45. package/dist/proxmox/ceph-flag.d.ts +5 -5
  46. package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
  47. package/dist/proxmox/ceph-fs.d.ts +16 -16
  48. package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
  49. package/dist/proxmox/ceph-osd.d.ts +5 -5
  50. package/dist/proxmox/ceph-pool-form.d.ts +13 -13
  51. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
  52. package/dist/proxmox/ceph-pool.d.ts +3 -3
  53. package/dist/proxmox/client.d.ts +2 -2
  54. package/dist/proxmox/credential-literals.d.ts +36 -0
  55. package/dist/proxmox/credential-literals.d.ts.map +1 -0
  56. package/dist/proxmox/credentials.d.ts +8 -8
  57. package/dist/proxmox/credentials.d.ts.map +1 -1
  58. package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
  59. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
  60. package/dist/proxmox/fake-pve.d.ts +5 -0
  61. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  62. package/dist/proxmox/firewall-alias-form.d.ts +1 -1
  63. package/dist/proxmox/firewall-alias.d.ts +1 -1
  64. package/dist/proxmox/generated/pbs.d.ts +279 -1
  65. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  66. package/dist/proxmox/generated/pve.d.ts +58 -1
  67. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  68. package/dist/proxmox/group.d.ts +8 -8
  69. package/dist/proxmox/ha-resource.d.ts +3 -2
  70. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  71. package/dist/proxmox/ha-rule-form.d.ts +5 -5
  72. package/dist/proxmox/ha-rule.d.ts +3 -3
  73. package/dist/proxmox/index.d.ts +14 -0
  74. package/dist/proxmox/index.d.ts.map +1 -1
  75. package/dist/proxmox/index.js +935 -223
  76. package/dist/proxmox/index.js.map +69 -56
  77. package/dist/proxmox/lease-cache.d.ts +4 -4
  78. package/dist/proxmox/lxc-harness.d.ts +1 -1
  79. package/dist/proxmox/lxc-props.d.ts +2 -2
  80. package/dist/proxmox/lxc.d.ts +3 -3
  81. package/dist/proxmox/members.d.ts +3 -3
  82. package/dist/proxmox/network-apply-read.d.ts +5 -5
  83. package/dist/proxmox/network-apply.d.ts +9 -9
  84. package/dist/proxmox/node-network-form.d.ts +6 -6
  85. package/dist/proxmox/node-network.d.ts +17 -17
  86. package/dist/proxmox/node-network.d.ts.map +1 -1
  87. package/dist/proxmox/notification-matcher-form.d.ts +100 -0
  88. package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
  89. package/dist/proxmox/notification-matcher.d.ts +46 -0
  90. package/dist/proxmox/notification-matcher.d.ts.map +1 -0
  91. package/dist/proxmox/notification-target.d.ts +3 -2
  92. package/dist/proxmox/notification-target.d.ts.map +1 -1
  93. package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
  94. package/dist/proxmox/pbs-datastore.d.ts +10 -10
  95. package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
  96. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
  97. package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
  98. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
  99. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
  100. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
  101. package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
  102. package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
  103. package/dist/proxmox/pbs-notification-target.d.ts +123 -0
  104. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
  105. package/dist/proxmox/pbs-prune-job.d.ts +2 -2
  106. package/dist/proxmox/pbs-sync-job.d.ts +4 -4
  107. package/dist/proxmox/pbs-verify-job.d.ts +1 -1
  108. package/dist/proxmox/pool.d.ts +4 -4
  109. package/dist/proxmox/provision-baseline.d.ts +74 -0
  110. package/dist/proxmox/provision-baseline.d.ts.map +1 -0
  111. package/dist/proxmox/provision-bootstrap.d.ts +33 -0
  112. package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
  113. package/dist/proxmox/provision-cli-fake.d.ts +38 -0
  114. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
  115. package/dist/proxmox/provision-declare.d.ts +25 -0
  116. package/dist/proxmox/provision-declare.d.ts.map +1 -0
  117. package/dist/proxmox/replication-job.d.ts +9 -8
  118. package/dist/proxmox/replication-job.d.ts.map +1 -1
  119. package/dist/proxmox/resource.d.ts +6 -5
  120. package/dist/proxmox/resource.d.ts.map +1 -1
  121. package/dist/proxmox/sdn-apply-read.d.ts +7 -7
  122. package/dist/proxmox/sdn-apply.d.ts +1 -1
  123. package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
  124. package/dist/proxmox/sdn-subnet.d.ts +5 -5
  125. package/dist/proxmox/sdn-vnet.d.ts +5 -6
  126. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  127. package/dist/proxmox/storage.d.ts +4 -3
  128. package/dist/proxmox/storage.d.ts.map +1 -1
  129. package/dist/proxmox/user.d.ts +3 -2
  130. package/dist/proxmox/user.d.ts.map +1 -1
  131. package/dist/proxmox/values.d.ts +1 -1
  132. package/dist/proxmox/write-only.d.ts +35 -0
  133. package/dist/proxmox/write-only.d.ts.map +1 -0
  134. package/dist/proxmox/zfs-pool-write.d.ts +1 -1
  135. package/dist/proxmox/zfs-pool.d.ts +5 -5
  136. package/dist/talos/credentials.d.ts +1 -1
  137. package/dist/talos/credentials.d.ts.map +1 -1
  138. package/dist/talos/index.js.map +1 -1
  139. package/dist/verify/fake-engine.d.ts +5 -0
  140. package/dist/verify/fake-engine.d.ts.map +1 -1
  141. package/docs/adopt-verify.md +21 -5
  142. package/docs/adopted-deploys.md +9 -1
  143. package/docs/ownership.md +26 -1
  144. package/docs/pbs-alertmanager-body.md +73 -0
  145. package/docs/pbs-notifications.md +163 -0
  146. package/docs/provision-baseline.md +106 -0
  147. package/docs/proxmox-lxc-adopt.md +2 -2
  148. package/docs/proxmox-lxc.md +5 -4
  149. package/docs/proxmox.md +9 -0
  150. package/package.json +1 -1
  151. package/src/caddy/admin-guard.ts +1 -1
  152. package/src/caddy/local-admin.ts +1 -1
  153. package/src/cloudflare/client.ts +1 -1
  154. package/src/cloudflare/lock-rules.ts +1 -1
  155. package/src/forgejo/branch-protection-form.ts +1 -1
  156. package/src/forgejo/branch-protection.ts +1 -1
  157. package/src/forgejo/client.ts +1 -1
  158. package/src/forgejo/org-actions-secrets.ts +1 -1
  159. package/src/forgejo/org-label.ts +1 -1
  160. package/src/forgejo/org-team.ts +1 -1
  161. package/src/forgejo/repo-webhook.ts +1 -1
  162. package/src/forgejo/repository.ts +2 -2
  163. package/src/forgejo/resource.ts +2 -2
  164. package/src/forgejo/team-member.ts +1 -1
  165. package/src/openbao/README.md +4 -4
  166. package/src/openbao/auth-role.ts +1 -1
  167. package/src/openbao/bao-http.test.ts +2 -2
  168. package/src/openbao/bao-http.ts +6 -6
  169. package/src/openbao/bao-status.test.ts +1 -1
  170. package/src/openbao/bao-status.ts +1 -1
  171. package/src/openbao/cloudflare-group-scope.test.ts +1 -1
  172. package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
  173. package/src/openbao/cloudflare-permission-groups.ts +3 -3
  174. package/src/openbao/cloudflare-policy.ts +1 -1
  175. package/src/openbao/cloudflare-role-form.ts +2 -2
  176. package/src/openbao/cloudflare-roles-expand.ts +1 -1
  177. package/src/openbao/digest.ts +1 -1
  178. package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
  179. package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
  180. package/src/openbao/forgejo-bootstrap.ts +4 -4
  181. package/src/openbao/kubernetes-role-form.test.ts +5 -5
  182. package/src/openbao/mount.ts +1 -1
  183. package/src/openbao/pki-role.ts +1 -1
  184. package/src/openbao/policy-wire.test.ts +3 -3
  185. package/src/openbao/policy.ts +2 -2
  186. package/src/openbao/proxmox-role-form.ts +5 -5
  187. package/src/openbao/proxmox-role.ts +11 -11
  188. package/src/openbao/ssh-role.ts +3 -3
  189. package/src/proxmox/acl.ts +4 -4
  190. package/src/proxmox/adopt-noop.test.ts +6 -6
  191. package/src/proxmox/alertmanager-body.ts +125 -0
  192. package/src/proxmox/api-token-form.ts +4 -4
  193. package/src/proxmox/api-token.ts +13 -13
  194. package/src/proxmox/ceph-daemon-form.ts +3 -3
  195. package/src/proxmox/ceph-daemon.ts +20 -20
  196. package/src/proxmox/ceph-flag.ts +7 -7
  197. package/src/proxmox/ceph-fs-wire.ts +7 -7
  198. package/src/proxmox/ceph-fs.ts +18 -18
  199. package/src/proxmox/ceph-osd-tree.ts +16 -16
  200. package/src/proxmox/ceph-osd.ts +6 -6
  201. package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
  202. package/src/proxmox/ceph-pool-form.ts +13 -13
  203. package/src/proxmox/ceph-pool.ts +6 -6
  204. package/src/proxmox/client.test.ts +3 -3
  205. package/src/proxmox/client.ts +3 -3
  206. package/src/proxmox/credential-literals.test.ts +148 -0
  207. package/src/proxmox/credential-literals.ts +74 -0
  208. package/src/proxmox/credentials.test.ts +6 -8
  209. package/src/proxmox/credentials.ts +8 -8
  210. package/src/proxmox/fake-pbs-notify.ts +108 -0
  211. package/src/proxmox/fake-pve.ts +12 -2
  212. package/src/proxmox/firewall-alias-form.ts +1 -1
  213. package/src/proxmox/firewall-alias.ts +1 -1
  214. package/src/proxmox/generated/pbs.ts +330 -1
  215. package/src/proxmox/generated/pve.ts +63 -1
  216. package/src/proxmox/group.ts +10 -10
  217. package/src/proxmox/ha-resource.ts +3 -2
  218. package/src/proxmox/ha-rule-form.ts +5 -5
  219. package/src/proxmox/ha-rule.ts +3 -3
  220. package/src/proxmox/index.test.ts +10 -0
  221. package/src/proxmox/index.ts +32 -0
  222. package/src/proxmox/lease-cache.test.ts +19 -19
  223. package/src/proxmox/lease-cache.ts +4 -4
  224. package/src/proxmox/lxc-adopt.test.ts +3 -3
  225. package/src/proxmox/lxc-create-form.ts +1 -1
  226. package/src/proxmox/lxc-guards.test.ts +4 -4
  227. package/src/proxmox/lxc-harness.ts +3 -3
  228. package/src/proxmox/lxc-judge.test.ts +5 -5
  229. package/src/proxmox/lxc-lifecycle.ts +1 -1
  230. package/src/proxmox/lxc-props.ts +2 -2
  231. package/src/proxmox/lxc-read.test.ts +3 -3
  232. package/src/proxmox/lxc-strict-adopt.test.ts +1 -1
  233. package/src/proxmox/lxc-volume.test.ts +9 -9
  234. package/src/proxmox/lxc-volume.ts +1 -1
  235. package/src/proxmox/lxc-wire.ts +1 -1
  236. package/src/proxmox/lxc.ts +3 -3
  237. package/src/proxmox/members.test.ts +1 -1
  238. package/src/proxmox/members.ts +3 -3
  239. package/src/proxmox/metric-server-otel.ts +2 -2
  240. package/src/proxmox/network-apply-read.ts +10 -10
  241. package/src/proxmox/network-apply.ts +10 -10
  242. package/src/proxmox/node-network-form.ts +7 -7
  243. package/src/proxmox/node-network.ts +18 -18
  244. package/src/proxmox/notification-matcher-form.ts +221 -0
  245. package/src/proxmox/notification-matcher.test.ts +234 -0
  246. package/src/proxmox/notification-matcher.ts +76 -0
  247. package/src/proxmox/notification-target.ts +3 -2
  248. package/src/proxmox/pbs-datastore-guard.ts +1 -1
  249. package/src/proxmox/pbs-datastore.ts +10 -10
  250. package/src/proxmox/pbs-notification-matcher.ts +81 -0
  251. package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
  252. package/src/proxmox/pbs-notification-target-form.ts +229 -0
  253. package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
  254. package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
  255. package/src/proxmox/pbs-notification-target-wire.ts +112 -0
  256. package/src/proxmox/pbs-notification-target.test.ts +226 -0
  257. package/src/proxmox/pbs-notification-target.ts +139 -0
  258. package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
  259. package/src/proxmox/pbs-prune-job.ts +2 -2
  260. package/src/proxmox/pbs-sync-job.ts +4 -4
  261. package/src/proxmox/pbs-verify-job.ts +1 -1
  262. package/src/proxmox/pool.test.ts +2 -2
  263. package/src/proxmox/pool.ts +4 -4
  264. package/src/proxmox/provision-baseline.test.ts +87 -0
  265. package/src/proxmox/provision-baseline.ts +162 -0
  266. package/src/proxmox/provision-bootstrap.test.ts +132 -0
  267. package/src/proxmox/provision-bootstrap.ts +135 -0
  268. package/src/proxmox/provision-cli-fake.ts +217 -0
  269. package/src/proxmox/provision-declare.test.ts +111 -0
  270. package/src/proxmox/provision-declare.ts +103 -0
  271. package/src/proxmox/replication-job.ts +9 -8
  272. package/src/proxmox/resource.ts +9 -9
  273. package/src/proxmox/sdn-apply-read.test.ts +16 -16
  274. package/src/proxmox/sdn-apply-read.ts +7 -7
  275. package/src/proxmox/sdn-apply.ts +3 -3
  276. package/src/proxmox/sdn-subnet-form.ts +1 -1
  277. package/src/proxmox/sdn-subnet.ts +5 -5
  278. package/src/proxmox/sdn-vnet.ts +5 -6
  279. package/src/proxmox/storage.ts +4 -3
  280. package/src/proxmox/user.ts +3 -2
  281. package/src/proxmox/values.test.ts +2 -2
  282. package/src/proxmox/values.ts +1 -1
  283. package/src/proxmox/write-only.test.ts +75 -0
  284. package/src/proxmox/write-only.ts +106 -0
  285. package/src/proxmox/zfs-pool-write.ts +1 -1
  286. package/src/proxmox/zfs-pool.ts +6 -6
  287. package/src/talos/credentials.ts +3 -3
  288. package/src/talos/values.test.ts +9 -9
  289. package/src/verify/cli.test.ts +1 -1
  290. package/src/verify/fake-engine.ts +8 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `provisionBootstrap` — the provisioning baseline (provision-baseline.ts) as the one-time root
3
+ * commands for a NEW cluster or node: a POSIX `sh` script, printed, never run from here.
4
+ *
5
+ * bun -e "import { provisionBootstrap } from '@homeflare/alchemy/proxmox';
6
+ * process.stdout.write(provisionBootstrap())" | ssh root@<node> sh
7
+ *
8
+ * ⛔ WHY IT EXISTS: THE PROVISION LANE CANNOT CREATE ITSELF. Every resource in this package runs as a
9
+ * token OpenBao mints for the provision user, and a cluster without that user, its group and its
10
+ * grant has nothing to mint for — the plan that would create them cannot authenticate. So root
11
+ * makes them once, and the stack adopts and keeps them (provision-declare.ts). This is the only
12
+ * hand step, and it is generated from the same baseline the stack declares.
13
+ * ⛔ NEVER A TOKEN, NEVER A PASSWORD. The users are created with neither: OpenBao mints short-lived
14
+ * tokens for them later, and an `@pve` user with no password cannot log in to the UI. Nothing
15
+ * secret is in the script, so it can be read, reviewed and pasted.
16
+ * ★ CHECK, THEN CHANGE — IDEMPOTENT BY CONSTRUCTION. Each object is read first (`pvesh get … json`,
17
+ * parsed with Perl's core JSON::PP, which every PVE node has), compared with the baseline, and
18
+ * written only when it differs, naming what it does. A second run prints `ok` for everything and
19
+ * writes nothing. It also CONVERGES: a role with other privileges is set to exactly the baseline,
20
+ * as the stack's `Proxmox.Role` would set it (role.ts: a declaration replaces the set).
21
+ * ★ ONE NODE PER CLUSTER. Users, groups, roles and ACLs live in `/etc/pve/user.cfg`, which pmxcfs
22
+ * shares across the cluster: a node joining a cluster that has the baseline needs nothing, and a
23
+ * run there is all `ok`. A new standalone node or a new cluster is where it creates.
24
+ * ⚠️ NAMES ARE NOT QUOTED INTO SAFETY, THEY ARE REFUSED INTO IT: provision-baseline.ts admits only
25
+ * characters that need no quoting in `sh` or in a Perl `q{}`.
26
+ */
27
+ import {
28
+ type ProvisionBaseline,
29
+ type ProvisionNames,
30
+ provisionBaseline,
31
+ } from './provision-baseline.ts';
32
+
33
+ const HEADER = `#!/bin/sh
34
+ # Provisioning baseline bootstrap, generated by @homeflare/alchemy (provisionBootstrap).
35
+ # Run ONCE, as root, on one node of a NEW cluster (or a new standalone node). Every step reads
36
+ # first and writes only what differs; a second run changes nothing. No token, no password.
37
+ set -eu
38
+ [ "$(id -u)" -eq 0 ] || { echo 'bootstrap: run this as root' >&2; exit 1; }
39
+ command -v pvesh >/dev/null 2>&1 || { echo 'bootstrap: no pvesh; run this on a PVE node' >&2; exit 1; }
40
+ perl -MJSON::PP -e 1 || { echo 'bootstrap: perl cannot load JSON::PP' >&2; exit 1; }
41
+
42
+ # get <api-path> <perl>: print <perl> evaluated over the object at <api-path> (as $d).
43
+ # Fails, silently, when the object does not exist: the empty input is not JSON.
44
+ get() {
45
+ pvesh get "$1" --output-format json 2>/dev/null | perl -MJSON::PP -0777 -ne "\\$d = decode_json(\\$_); $2" 2>/dev/null
46
+ }
47
+ `;
48
+
49
+ /** A Perl string literal with no interpolation — `@pve` in a double-quoted one is an array. */
50
+ const perlText = (text: string) => `q{${text}}`;
51
+
52
+ /** The user fields `Proxmox.User` compares, as one line both sides can produce. */
53
+ const USER_STATE =
54
+ 'my $g = $d->{groups} // []; $g = [split /,/, $g] unless ref $g; ' +
55
+ 'print join(";", "groups=" . join(",", sort @$g), "enable=" . (($d->{enable} // 1) ? 1 : 0), ' +
56
+ '"expire=" . ($d->{expire} // 0), "email=" . ($d->{email} // ""), ' +
57
+ '"firstname=" . ($d->{firstname} // ""), "lastname=" . ($d->{lastname} // ""), ' +
58
+ '"comment=" . ($d->{comment} // ""))';
59
+
60
+ const roleStep = ({ role }: ProvisionBaseline): string => {
61
+ const privs = [...role.privs].sort().join(',');
62
+ return `
63
+ # Role ${role.roleid}: exactly the provision privileges, on no path yet.
64
+ want='${privs}'
65
+ if have=$(get /access/roles/${role.roleid} 'print join(",", sort grep { /\\./ } keys %$d)'); then
66
+ if [ "$have" = "$want" ]; then echo 'role ${role.roleid}: ok'
67
+ else echo 'role ${role.roleid}: setting the baseline privileges'; pveum role modify ${role.roleid} --privs "$want"; fi
68
+ else
69
+ echo 'role ${role.roleid}: adding'; pveum role add ${role.roleid} --privs "$want"
70
+ fi
71
+ `;
72
+ };
73
+
74
+ const groupStep = ({ group }: ProvisionBaseline): string => `
75
+ # Group ${group.groupid}: OpenBao may mint only for its members.
76
+ if have=$(get /access/groups/${group.groupid} 'print $d->{comment} // ""'); then
77
+ if [ "$have" = '${group.comment}' ]; then echo 'group ${group.groupid}: ok'
78
+ else echo 'group ${group.groupid}: setting the comment'; pveum group modify ${group.groupid} --comment '${group.comment}'; fi
79
+ else
80
+ echo 'group ${group.groupid}: adding'; pveum group add ${group.groupid} --comment '${group.comment}'
81
+ fi
82
+ `;
83
+
84
+ const userStep = (user: ProvisionBaseline['users'][number]): string => {
85
+ const groups = [...user.groups].sort().join(',');
86
+ const want = `groups=${groups};enable=1;expire=0;email=;firstname=;lastname=;comment=${user.comment}`;
87
+ const fields = `--groups ${groups} --comment '${user.comment}'`;
88
+ return `
89
+ # User ${user.userid} (the ${user.lane} lane): in ${groups}, enabled, no password.
90
+ if have=$(get /access/users/${user.userid} '${USER_STATE}'); then
91
+ if [ "$have" = '${want}' ]; then echo 'user ${user.userid}: ok'
92
+ else echo 'user ${user.userid}: setting groups, comment and account fields'
93
+ pveum user modify ${user.userid} ${fields} --enable 1 --expire 0 --email '' --firstname '' --lastname ''; fi
94
+ else
95
+ echo 'user ${user.userid}: adding'; pveum user add ${user.userid} ${fields}
96
+ fi
97
+ `;
98
+ };
99
+
100
+ const grantStep = (grant: ProvisionBaseline['grants'][number]): string => {
101
+ const match =
102
+ `print scalar grep { $_->{path} eq "/" && $_->{type} eq "user" && ` +
103
+ `$_->{ugid} eq ${perlText(grant.userid)} && $_->{roleid} eq ${perlText(grant.roleid)} && ` +
104
+ `($_->{propagate} // 1) } @$d`;
105
+ return `
106
+ # Grant: ${grant.userid} holds ${grant.roleid} on /, propagated (the ${grant.lane} lane).
107
+ if [ "$(get /access/acl '${match}')" = 1 ]; then echo 'grant ${grant.userid} ${grant.roleid} /: ok'
108
+ else echo 'grant ${grant.userid} ${grant.roleid} /: adding'; pveum acl modify / --users ${grant.userid} --roles ${grant.roleid} --propagate 1; fi
109
+ `;
110
+ };
111
+
112
+ /** A role the baseline binds but does not own — the read lane's — must already exist. */
113
+ const requireRole = (roleid: string): string => `
114
+ get /access/roles/${roleid} 'print 1' >/dev/null || { echo 'bootstrap: role ${roleid} does not exist; create it, or name another readRole' >&2; exit 1; }
115
+ `;
116
+
117
+ /**
118
+ * The script for `names` (defaults: `PROVISION_DEFAULTS`). Pure: it reads nothing and runs nothing.
119
+ * Throws, like `provisionBaseline`, on a name the baseline refuses.
120
+ */
121
+ export const provisionBootstrap = (names: ProvisionNames = {}): string => {
122
+ const baseline = provisionBaseline(names);
123
+ const borrowed = baseline.grants
124
+ .map((grant) => grant.roleid)
125
+ .filter((roleid) => roleid !== baseline.role.roleid);
126
+ return [
127
+ HEADER,
128
+ ...[...new Set(borrowed)].map(requireRole),
129
+ roleStep(baseline),
130
+ groupStep(baseline),
131
+ ...baseline.users.map(userStep),
132
+ ...baseline.grants.map(grantStep),
133
+ `\necho 'provision baseline: done. Next: point the OpenBao proxmox mount at these users, then deploy the stack that declares this baseline with --adopt.'\n`,
134
+ ].join('');
135
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A fake `pvesh` / `pveum` / `id` for the bootstrap tests: the generated script runs for real, under
3
+ * `sh`, with real Perl parsing the JSON, against an access config held in one JSON file — so what is
4
+ * tested is the script a person pipes to a node, not a model of it.
5
+ *
6
+ * ⛔ TEST-ONLY. No provider imports this file. Run as a program (`import.meta.main`), it IS the fake
7
+ * CLI; imported, it is the harness that writes the wrappers and runs a script against them.
8
+ * ★ IT REFUSES WHAT PVE REFUSES WHERE ORDER MATTERS: adding an object that exists, modifying one
9
+ * that does not, a user in a group that does not exist, a grant of a role that does not exist.
10
+ * A script that runs its steps in the wrong order fails here as it would on a node.
11
+ * ★ READS ANSWER IN THE ITEM SHAPES access/*.ts DOCUMENT: a role is a privilege map, a user's
12
+ * groups an array, empty strings omitted. `apiView` is also what the engine test's fake PVE
13
+ * serves, so the bootstrap's result and the declaration are compared on one picture.
14
+ */
15
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ export type CliUser = {
20
+ comment: string;
21
+ email: string;
22
+ enable: number;
23
+ expire: number;
24
+ firstname: string;
25
+ lastname: string;
26
+ groups: string[];
27
+ };
28
+ export type CliGrant = {
29
+ path: string;
30
+ type: string;
31
+ ugid: string;
32
+ roleid: string;
33
+ propagate: number;
34
+ };
35
+ export type CliState = {
36
+ roles: Record<string, string[]>;
37
+ groups: Record<string, { comment: string }>;
38
+ users: Record<string, CliUser>;
39
+ acl: CliGrant[];
40
+ };
41
+
42
+ /** A cluster as PVE installs it: the built-in auditor role, and nothing of the baseline. */
43
+ export const freshCluster = (): CliState => ({
44
+ acl: [],
45
+ groups: {},
46
+ roles: {
47
+ PVEAuditor: [
48
+ 'Datastore.Audit',
49
+ 'Mapping.Audit',
50
+ 'Pool.Audit',
51
+ 'SDN.Audit',
52
+ 'Sys.Audit',
53
+ 'VM.Audit',
54
+ ],
55
+ },
56
+ users: {},
57
+ });
58
+
59
+ const text = (key: string, value: string) => (value === '' ? {} : { [key]: value });
60
+
61
+ /** Every item GET the scripts and the providers make, keyed `access/…` without a leading slash. */
62
+ export const apiView = (state: CliState): Record<string, unknown> => {
63
+ const view: Record<string, unknown> = { 'access/acl': state.acl };
64
+ for (const [id, privs] of Object.entries(state.roles)) {
65
+ view[`access/roles/${id}`] = Object.fromEntries(privs.map((priv) => [priv, 1]));
66
+ }
67
+ for (const [id, group] of Object.entries(state.groups)) {
68
+ const members = Object.keys(state.users).filter((u) => state.users[u]?.groups.includes(id));
69
+ view[`access/groups/${id}`] = { ...text('comment', group.comment), members };
70
+ }
71
+ for (const [id, user] of Object.entries(state.users)) {
72
+ view[`access/users/${id}`] = {
73
+ ...text('comment', user.comment),
74
+ ...text('email', user.email),
75
+ ...text('firstname', user.firstname),
76
+ ...text('lastname', user.lastname),
77
+ enable: user.enable,
78
+ expire: user.expire,
79
+ groups: user.groups,
80
+ tokens: {},
81
+ };
82
+ }
83
+ return view;
84
+ };
85
+
86
+ const flags = (args: string[]) => {
87
+ const out: Record<string, string> = {};
88
+ for (let i = 0; i < args.length; i += 2)
89
+ out[(args[i] ?? '').replace(/^--/, '')] = args[i + 1] ?? '';
90
+ return out;
91
+ };
92
+
93
+ /** One pveum call against `state`; returns the refusal PVE would print, or undefined. */
94
+ const pveum = (state: CliState, [kind, verb, id = '', ...rest]: string[]): string | undefined => {
95
+ const f = flags(rest);
96
+ const list = (csv: string | undefined) => (csv ?? '').split(',').filter((s) => s !== '');
97
+ if (kind === 'role') {
98
+ if ((verb === 'add') === id in state.roles) return `role '${id}': wrong state for ${verb}`;
99
+ state.roles[id] = list(f['privs']);
100
+ } else if (kind === 'group') {
101
+ if ((verb === 'add') === id in state.groups) return `group '${id}': wrong state for ${verb}`;
102
+ state.groups[id] = { comment: f['comment'] ?? '' };
103
+ } else if (kind === 'user') {
104
+ const had = state.users[id];
105
+ if ((verb === 'add') === (had !== undefined)) return `user '${id}': wrong state for ${verb}`;
106
+ const groups = f['groups'] === undefined ? (had?.groups ?? []) : list(f['groups']);
107
+ const missing = groups.find((g) => !(g in state.groups));
108
+ if (missing !== undefined) return `no such group '${missing}'`;
109
+ const base: CliUser = had ?? {
110
+ comment: '',
111
+ email: '',
112
+ enable: 1,
113
+ expire: 0,
114
+ firstname: '',
115
+ lastname: '',
116
+ groups: [],
117
+ };
118
+ state.users[id] = {
119
+ comment: f['comment'] ?? base.comment,
120
+ email: f['email'] ?? base.email,
121
+ enable: f['enable'] === undefined ? base.enable : Number(f['enable']),
122
+ expire: f['expire'] === undefined ? base.expire : Number(f['expire']),
123
+ firstname: f['firstname'] ?? base.firstname,
124
+ groups,
125
+ lastname: f['lastname'] ?? base.lastname,
126
+ };
127
+ } else if (kind === 'acl' && verb === 'modify') {
128
+ const roleid = f['roles'] ?? '';
129
+ if (!(roleid in state.roles)) return `role '${roleid}' does not exist`;
130
+ const grant = {
131
+ path: id,
132
+ propagate: Number(f['propagate'] ?? 1),
133
+ roleid,
134
+ type: 'user',
135
+ ugid: f['users'] ?? '',
136
+ };
137
+ state.acl = state.acl.filter(
138
+ (g) => !(g.path === id && g.ugid === grant.ugid && g.roleid === roleid),
139
+ );
140
+ state.acl.push(grant);
141
+ } else return `unknown pveum call: ${kind} ${verb}`;
142
+ return undefined;
143
+ };
144
+
145
+ /** The fake CLI's entry point: `<tool> <args…>`, with FAKE_PVE_STATE and FAKE_PVE_LOG set. */
146
+ const runCli = ([tool, ...args]: string[]): number => {
147
+ if (tool === 'id') {
148
+ process.stdout.write('0\n');
149
+ return 0;
150
+ }
151
+ const file = process.env['FAKE_PVE_STATE'] ?? '';
152
+ const state = JSON.parse(readFileSync(file, 'utf8')) as CliState;
153
+ if (tool === 'pvesh') {
154
+ const found = apiView(state)[(args[1] ?? '').replace(/^\//, '')];
155
+ if (found === undefined) {
156
+ process.stderr.write(`no such object ${args[1] ?? ''}\n`);
157
+ return 2;
158
+ }
159
+ process.stdout.write(JSON.stringify(found));
160
+ return 0;
161
+ }
162
+ appendFileSync(process.env['FAKE_PVE_LOG'] ?? '', `pveum ${args.join(' ')}\n`);
163
+ const refused = pveum(state, args);
164
+ if (refused !== undefined) {
165
+ process.stderr.write(`${refused}\n`);
166
+ return 255;
167
+ }
168
+ writeFileSync(file, JSON.stringify(state));
169
+ return 0;
170
+ };
171
+
172
+ if (import.meta.main) process.exit(runCli(process.argv.slice(2)));
173
+
174
+ export type ScriptRun = {
175
+ code: number;
176
+ stdout: string;
177
+ stderr: string;
178
+ writes: string[];
179
+ state: CliState;
180
+ };
181
+
182
+ /** Run `script` under `sh` against `state`, with the fakes first on PATH. */
183
+ export const runScript = async (script: string, state: CliState): Promise<ScriptRun> => {
184
+ const dir = mkdtempSync(join(tmpdir(), 'hf-bootstrap-'));
185
+ for (const tool of ['id', 'pvesh', 'pveum']) {
186
+ const wrapper = `#!/bin/sh\nexec '${process.execPath}' '${import.meta.path}' ${tool} "$@"\n`;
187
+ writeFileSync(join(dir, tool), wrapper, { mode: 0o755 });
188
+ }
189
+ const stateFile = join(dir, 'state.json');
190
+ const log = join(dir, 'pveum.log');
191
+ writeFileSync(stateFile, JSON.stringify(state));
192
+ writeFileSync(log, '');
193
+ try {
194
+ const child = Bun.spawn(['sh', '-s'], {
195
+ env: {
196
+ FAKE_PVE_LOG: log,
197
+ FAKE_PVE_STATE: stateFile,
198
+ PATH: `${dir}:${process.env['PATH'] ?? ''}`,
199
+ },
200
+ stderr: 'pipe',
201
+ stdin: new TextEncoder().encode(script),
202
+ stdout: 'pipe',
203
+ });
204
+ const [stdout, stderr, code] = await Promise.all([
205
+ new Response(child.stdout).text(),
206
+ new Response(child.stderr).text(),
207
+ child.exited,
208
+ ]);
209
+ const writes = readFileSync(log, 'utf8')
210
+ .split('\n')
211
+ .filter((line) => line !== '');
212
+ const after = JSON.parse(readFileSync(stateFile, 'utf8')) as CliState;
213
+ return { code, state: after, stderr, stdout, writes };
214
+ } finally {
215
+ rmSync(dir, { force: true, recursive: true });
216
+ }
217
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `declareProvisionBaseline` through Alchemy's real plan and apply, over a fake PVE serving what the
3
+ * generated bootstrap made (provision-cli-fake.ts `apiView`) — the proof that the two halves are
4
+ * one baseline: after the bootstrap, the declaration adopts every object and writes NOTHING.
5
+ * Also pinned: a hand edit shows up as a diff; every resource retains; `adopt` is piped only when
6
+ * asked; and a refused name fails before anything is declared.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { push } from 'alchemy';
10
+ import { Stack } from 'alchemy/Stack';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Layer from 'effect/Layer';
13
+ import { engineOver } from '../verify/fake-engine.ts';
14
+ import { ProxmoxAclProvider } from './acl.ts';
15
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
16
+ import { ProxmoxGroupProvider } from './group.ts';
17
+ import { type CliState, apiView, freshCluster, runScript } from './provision-cli-fake.ts';
18
+ import { provisionBootstrap } from './provision-bootstrap.ts';
19
+ import { type DeclareProvisionOptions, declareProvisionBaseline } from './provision-declare.ts';
20
+ import { ProxmoxRoleProvider } from './role.ts';
21
+ import { ProxmoxUserProvider } from './user.ts';
22
+
23
+ const ALL = [
24
+ 'pve-role',
25
+ 'pve-mint-group',
26
+ 'pve-provision-user',
27
+ 'pve-read-user',
28
+ 'pve-provision-grant',
29
+ 'pve-read-grant',
30
+ ];
31
+
32
+ const bootstrapped = async (): Promise<CliState> => {
33
+ const run = await runScript(provisionBootstrap(), freshCluster());
34
+ if (run.code !== 0) throw new Error(`bootstrap failed: ${run.stderr}`);
35
+ return run.state;
36
+ };
37
+
38
+ const engineOn = (state: CliState) => {
39
+ const live = apiView(state);
40
+ const fake = fakePve((call) => (call.method === 'GET' ? live[call.path] : 'UPID:fake'));
41
+ const providers = Layer.mergeAll(
42
+ ProxmoxAclProvider(),
43
+ ProxmoxGroupProvider(),
44
+ ProxmoxRoleProvider(),
45
+ ProxmoxUserProvider(),
46
+ );
47
+ return { engine: engineOver(providers.pipe(Layer.provideMerge(fake.layer))), fake };
48
+ };
49
+
50
+ describe('after the bootstrap, the declaration is a clean adoption', () => {
51
+ test('every object is adopted, and nothing is written', async () => {
52
+ const { engine, fake } = engineOn(await bootstrapped());
53
+ await withoutBao(async () => {
54
+ const report = await engine.verify(declareProvisionBaseline('pve', FAKE_TARGET));
55
+ expect(report.rows.map((row) => [row.fqn, row.diff, row.ok])).toEqual(
56
+ [...ALL].sort().map((fqn) => [fqn, 'noop', true]),
57
+ );
58
+ const deployed = await engine.deploy(declareProvisionBaseline('pve', FAKE_TARGET));
59
+ expect(deployed).toEqual(Object.fromEntries(ALL.map((fqn) => [fqn, 'adopted'])));
60
+ });
61
+ expect(fake.writes()).toEqual([]);
62
+ });
63
+
64
+ test('a privilege added by hand is a diff on the role, and nothing else', async () => {
65
+ const state = await bootstrapped();
66
+ state.roles['HfProvisioner']?.push('VM.Console');
67
+ const { engine } = engineOn(state);
68
+ await withoutBao(async () => {
69
+ const report = await engine.verify(declareProvisionBaseline('pve', FAKE_TARGET));
70
+ const changed = report.rows.filter((row) => row.diff !== 'noop');
71
+ expect(changed).toEqual([expect.objectContaining({ changed: ['privs'], fqn: 'pve-role' })]);
72
+ });
73
+ });
74
+ });
75
+
76
+ /** Register the declaration under a bare Stack and read back what Alchemy recorded per resource. */
77
+ const registered = async (options?: DeclareProvisionOptions) => {
78
+ const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
79
+ await Effect.runPromise(
80
+ push('lab', declareProvisionBaseline('pve', FAKE_TARGET, {}, options)).pipe(
81
+ Effect.provideService(Stack, spec as never),
82
+ ) as Effect.Effect<unknown>,
83
+ );
84
+ return spec.resources as Record<string, { Adopt?: boolean; RemovalPolicy?: string }>;
85
+ };
86
+
87
+ describe('what the helper registers', () => {
88
+ test('every resource retains, whatever the family default', async () => {
89
+ const resources = await registered();
90
+ expect(Object.keys(resources).sort()).toEqual(ALL.map((id) => `lab/${id}`).sort());
91
+ for (const each of Object.values(resources)) expect(each.RemovalPolicy).toBe('retain');
92
+ });
93
+
94
+ test.each([
95
+ [{ adopt: true }, true],
96
+ [{ adopt: false }, false],
97
+ [{}, undefined],
98
+ ] as const)('adopt %o is recorded as %p on every resource', async (options, expected) => {
99
+ for (const each of Object.values(await registered(options))) expect(each.Adopt).toBe(expected);
100
+ });
101
+
102
+ test('a refused name fails before anything is declared', async () => {
103
+ const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
104
+ const declared = declareProvisionBaseline('pve', FAKE_TARGET, { role: 'bad role' });
105
+ const exit = await Effect.runPromiseExit(
106
+ declared.pipe(Effect.provideService(Stack, spec as never)) as Effect.Effect<unknown, unknown>,
107
+ );
108
+ expect(String(exit)).toContain('role `bad role` is not a PVE role id');
109
+ expect(spec.resources).toEqual({});
110
+ });
111
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `declareProvisionBaseline` — the provisioning baseline (provision-baseline.ts) as resources, so a
3
+ * stack adopts what the bootstrap made and keeps it: the role, the mint group, one user per lane
4
+ * (its group membership included) and the grant binding each user to its role on `/`.
5
+ *
6
+ * const lane = yield* declareProvisionBaseline('pve', target);
7
+ * // names are generic by default; a site passes its own:
8
+ * const lane = yield* declareProvisionBaseline('pve', target, { role: 'Provisioner' });
9
+ *
10
+ * Provide `ProxmoxRoleProvider()`, `ProxmoxGroupProvider()`, `ProxmoxUserProvider()` and
11
+ * `ProxmoxAclProvider()` with `FetchHttpClient.layer`.
12
+ *
13
+ * ⛔ EVERY RESOURCE HERE RETAINS. This is the branch the whole package sits on (user.ts): dropping
14
+ * the call from a stack must drop state, never the role, the users or their grants — deleting the
15
+ * provision user revokes every lease at once, the plan that would recreate it included.
16
+ * ⛔ ADOPTION IS THE OPERATOR'S TO STATE, AS EVERYWHERE IN THE KIT (docs/ownership.md). The first
17
+ * deploy after the bootstrap finds all of this live with no state: run it with `--adopt`, or pass
18
+ * `{ adopt: true }`, which pipes `adopt(true)` onto each resource. `{ adopt: false }` pins them
19
+ * refused under a deploy-wide `--adopt`. Left out, the deploy's own policy decides.
20
+ * ⚠️ TODAY THE FOUR FAMILIES STILL READ A LIVE OBJECT WITH NO STATE AS OURS (the `pveHandlers`
21
+ * limit in ownership.md), so the flag is not yet what stops a claim. It is stated here so that
22
+ * when they follow the rule, a stack built on this helper already says what it means.
23
+ * ★ THE BOOTSTRAP MAKES EXACTLY THIS, so a clean adoption writes nothing: the same privileges, the
24
+ * same comment, the same single group per user (provision-bootstrap.ts reads the same baseline).
25
+ * ★ PLAIN NAMES, NOT ONE RESOURCE'S OUTPUT IN ANOTHER'S PROPS. An Output would order the creates,
26
+ * but Alchemy skips the adoption probe while any prop is one (Plan.ts), so the first deploy after
27
+ * the bootstrap would plan `create` for objects that are all there. Order never matters here:
28
+ * nothing in this package can run before the bootstrap, and after it everything exists.
29
+ */
30
+ import { adopt } from 'alchemy/AdoptPolicy';
31
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
32
+ import * as Effect from 'effect/Effect';
33
+ import { ProxmoxAcl } from './acl.ts';
34
+ import type { PveTarget } from './credentials.ts';
35
+ import { ProxmoxGroup } from './group.ts';
36
+ import { type ProvisionNames, provisionBaseline } from './provision-baseline.ts';
37
+ import { ProxmoxRole } from './role.ts';
38
+ import { ProxmoxUser } from './user.ts';
39
+
40
+ export interface DeclareProvisionOptions {
41
+ /** `true`/`false` pipe `adopt(…)` onto every resource; omitted, the deploy's policy decides. */
42
+ readonly adopt?: boolean;
43
+ }
44
+
45
+ /**
46
+ * The baseline for one cluster. `id` prefixes every logical id (`<id>-role`, `<id>-mint-group`,
47
+ * `<id>-provision-user`, `<id>-provision-grant`, and the `read` pair), so one stack can declare a
48
+ * baseline per cluster. `users` and `grants` come back in `baseline.users` / `baseline.grants`
49
+ * order: provision, then read. Fails, before declaring anything, on a name the baseline refuses.
50
+ */
51
+ export const declareProvisionBaseline = (
52
+ id: string,
53
+ target: PveTarget,
54
+ names: ProvisionNames = {},
55
+ options: DeclareProvisionOptions = {},
56
+ ) =>
57
+ Effect.gen(function* () {
58
+ const baseline = yield* Effect.try({
59
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
60
+ try: () => provisionBaseline(names),
61
+ });
62
+ const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {
63
+ const kept = resource.pipe(RemovalPolicy.retain());
64
+ return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
65
+ };
66
+ const role = yield* owned(
67
+ ProxmoxRole(`${id}-role`, {
68
+ privs: [...baseline.role.privs],
69
+ roleid: baseline.role.roleid,
70
+ target,
71
+ }),
72
+ );
73
+ const group = yield* owned(ProxmoxGroup(`${id}-mint-group`, { ...baseline.group, target }));
74
+ // ★ `Effect.all` over mapped declarations (sequential), as cloudflare-permission-groups.ts does:
75
+ // oxlint's unicorn/no-array-for-each cannot tell `Effect.forEach` from `Array#forEach`.
76
+ const users = yield* Effect.all(
77
+ baseline.users.map((user) =>
78
+ owned(
79
+ ProxmoxUser(`${id}-${user.lane}-user`, {
80
+ comment: user.comment,
81
+ groups: [...user.groups],
82
+ target,
83
+ userid: user.userid,
84
+ }),
85
+ ),
86
+ ),
87
+ );
88
+ const grants = yield* Effect.all(
89
+ baseline.grants.map((grant) =>
90
+ owned(
91
+ ProxmoxAcl(`${id}-${grant.lane}-grant`, {
92
+ path: '/',
93
+ propagate: true,
94
+ roleid: grant.roleid,
95
+ target,
96
+ type: 'user',
97
+ ugid: grant.userid,
98
+ }),
99
+ ),
100
+ ),
101
+ );
102
+ return { baseline, grants, group, role, users };
103
+ });
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * `Proxmox.ReplicationJob` — a guest's ZFS storage replication to a second node, declared.
3
3
  *
4
- * ⛔ NOTHING ON TB4 CAN USE THIS FAMILY, AND THAT IS SAID HERE RATHER THAN FOUND OUT LATER. PVE
4
+ * ⛔ NOTHING ON C1 CAN USE THIS FAMILY, AND THAT IS SAID HERE RATHER THAN FOUND OUT LATER. PVE
5
5
  * storage replication is ZFS-only: `POST /cluster/replication` calls `get_replicatable_volumes`
6
- * and refuses a guest whose disks are anywhere else with "No replicatable volumes found". TB4 is
6
+ * and refuses a guest whose disks are anywhere else with "No replicatable volumes found". C1 is
7
7
  * Ceph/RBD-backed — shared storage, where replication means nothing because every node already
8
- * sees the volume — so `GET /cluster/replication` answers `[]` (MEASURED on n2, 2026-09-13) and
9
- * on this cluster always will. The file is written for a ZFS cluster; against TB4 a declaration
8
+ * sees the volume — so `GET /cluster/replication` answers `[]` (MEASURED on node-b, 2026-09-13) and
9
+ * on this cluster always will. The file is written for a ZFS cluster; against C1 a declaration
10
10
  * is a create that fails with PVE's own sentence, not a plan that quietly does nothing.
11
11
  *
12
- * ★ WHAT IS MEASURED HERE AND WHAT IS REASONED. Measured on n2 (pve-manager 9.2.11), read-only:
12
+ * ★ WHAT IS MEASURED HERE AND WHAT IS REASONED. Measured on node-b (pve-manager 9.2.11), read-only:
13
13
  * the published schema for all five methods; `PVE/ReplicationConfig.pm` and
14
14
  * `PVE/API2/ReplicationConfig.pm` line by line; `GET /cluster/replication` -> `[]`; and the ACL
15
15
  * row `{"path":"/","propagate":1,"roleid":"PVEAuditor","ugid":"hf-read@pve"}`. NOT measured: any
@@ -45,12 +45,13 @@
45
45
  * declarations differing only in `jobnum` but naming the same target are not two jobs; the second
46
46
  * is a create that fails.
47
47
  *
48
- * ⚠️ RECONCILE NEEDS `VM.Replicate` ON `/vms/<guest>`, WHICH `LXCProvisioner` DOES NOT HOLD. All
48
+ * ⚠️ RECONCILE NEEDS `VM.Replicate` ON `/vms/<guest>`, WHICH THE PROVISION ROLE DOES NOT HOLD. All
49
49
  * three writes check it (MEASURED in API2/ReplicationConfig.pm: create, update and delete each
50
50
  * call `$rpcenv->check($authuser, "/vms/$vmid", ['VM.Replicate'])`), and the role's 27 privileges
51
51
  * include `VM.Allocate`, `VM.Audit`, `VM.Backup`, `VM.Config.*` and `VM.PowerMgmt` but not
52
- * `VM.Replicate`. Widen it deliberately over SSH, preserving what is there, as `pool.ts` records:
53
- * `pveum role modify LXCProvisioner --privs "<existing>,VM.Replicate"`.
52
+ * `VM.Replicate` (`PROVISION_PRIVILEGES`). Do NOT widen it by hand: the baseline declares the
53
+ * role's exact set, so a hand-added privilege is removed on the next deploy. Grant a second role
54
+ * holding `VM.Replicate` on `/vms/<guest>` instead.
54
55
  * ★ THE READ LANE NEEDS NOTHING EXTRA, which is why there is no `readRole` below. The ITEM read
55
56
  * checks `VM.Audit` on `/vms/<guest>`, `hf-read@pve` holds the built-in `PVEAuditor` at `/`
56
57
  * with propagate — both MEASURED — and `PVEAuditor` carries `VM.Audit`. This family is not a
@@ -20,7 +20,7 @@ import { isResolved } from 'alchemy/Diff';
20
20
  import type { Input } from 'alchemy/Input';
21
21
  import * as Effect from 'effect/Effect';
22
22
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
23
- import { pve } from './client.ts';
23
+ import { type PveForm, pve } from './client.ts';
24
24
  import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
25
25
  import { formToSend } from './update-guard.ts';
26
26
 
@@ -71,21 +71,21 @@ export type WithPbsTarget = { target: PbsTarget };
71
71
  export type WithApiTarget = { target: ApiTarget };
72
72
 
73
73
  export type PveSpec<Props extends WithApiTarget, Attributes> = {
74
- /** `pools/house`, `nodes/n2/lxc/101` — where ONE object is read, updated and deleted. */
74
+ /** `pools/lab`, `nodes/node-b/lxc/101` — where ONE object is read, updated and deleted. */
75
75
  readonly path: (props: Props) => string;
76
- /** `pools`, `nodes/n2/lxc` — where a NEW one is POSTed. */
76
+ /** `pools`, `nodes/node-b/lxc` — where a NEW one is POSTed. */
77
77
  readonly collection: (props: Props) => string;
78
78
  /** Live JSON to attributes. Returning undefined means "this is not really there". */
79
79
  readonly attributes: (live: Record<string, unknown>, props: Props) => Attributes | undefined;
80
- /** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. */
81
- readonly createForm: (props: Props) => Record<string, string>;
80
+ /** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. Arrays: client.ts. */
81
+ readonly createForm: (props: Props) => PveForm;
82
82
  /**
83
83
  * The form for an update, or undefined when the object has no mutable fields.
84
84
  *
85
85
  * ⚠️ SOME PVE OBJECTS CANNOT BE UPDATED AT ALL. Returning undefined makes a changed prop a
86
86
  * REPLACE rather than a silent no-op, which is the honest answer for an immutable object.
87
87
  */
88
- readonly updateForm?: (props: Props) => Record<string, string>;
88
+ readonly updateForm?: (props: Props) => PveForm;
89
89
  /** True when live already matches props. Decides noop vs update. */
90
90
  readonly matches: (attributes: Attributes, props: Props) => boolean;
91
91
  /**
@@ -105,7 +105,7 @@ export type PveSpec<Props extends WithApiTarget, Attributes> = {
105
105
  export const pveOperations = <Props extends WithApiTarget, Attributes>(
106
106
  spec: PveSpec<Props, Attributes>,
107
107
  ) => {
108
- /** The live object, or undefined. ⚠️ A 404 is an ANSWER here, not a failure. */
108
+ /** The live object, or undefined. ⚠️ A 404 and `{"data": null}` are ANSWERS, not failures. */
109
109
  const read = (props: Props) =>
110
110
  pve<Record<string, unknown>>(
111
111
  props.target,
@@ -113,7 +113,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
113
113
  'GET',
114
114
  spec.path(props),
115
115
  ).pipe(
116
- Effect.map((data) => (data === undefined ? undefined : spec.attributes(data, props))),
116
+ Effect.map((data) => (data == null ? undefined : spec.attributes(data, props))),
117
117
  Effect.orElseSucceed(() => undefined),
118
118
  );
119
119
 
@@ -164,7 +164,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
164
164
  * `Proxmox.NodeNetwork` it is worse than pointless: a PUT under `/nodes/{node}/network`
165
165
  * STAGES a change into `/etc/network/interfaces.new`, so merely adopting the interface
166
166
  * a node already has would leave that node with a pending network change waiting for an
167
- * apply — on a cluster whose Ceph traffic rides vmbr1.11. Adoption has to be free.
167
+ * apply — on a cluster whose Ceph traffic rides vmbr1.42. Adoption has to be free.
168
168
  *
169
169
  * ★ SO THE CONDITION IS THE SAME PREDICATE `diff` USES. If `matches` is true the object
170
170
  * already says what the declaration says, and there is nothing to write — by