@homeflare/alchemy 0.10.1 → 0.12.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 (265) 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 +4 -4
  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/launchd/index.js.map +2 -2
  19. package/dist/openbao/auth-role.d.ts +1 -1
  20. package/dist/openbao/bao-http.d.ts +4 -4
  21. package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
  22. package/dist/openbao/cloudflare-role-form.d.ts +2 -2
  23. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
  24. package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
  25. package/dist/openbao/digest.d.ts +1 -1
  26. package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
  27. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
  28. package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
  29. package/dist/openbao/index.js +10 -8
  30. package/dist/openbao/index.js.map +18 -18
  31. package/dist/openbao/mount.d.ts +1 -1
  32. package/dist/openbao/pki-role.d.ts +1 -1
  33. package/dist/openbao/policy.d.ts +2 -2
  34. package/dist/openbao/proxmox-role-form.d.ts +2 -2
  35. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  36. package/dist/openbao/proxmox-role.d.ts +10 -10
  37. package/dist/openbao/ssh-role.d.ts +3 -3
  38. package/dist/ownership/adopting.d.ts +34 -0
  39. package/dist/ownership/adopting.d.ts.map +1 -0
  40. package/dist/ownership/probe.d.ts +15 -5
  41. package/dist/ownership/probe.d.ts.map +1 -1
  42. package/dist/ownership/rows.d.ts +12 -0
  43. package/dist/ownership/rows.d.ts.map +1 -1
  44. package/dist/ownership/store-fixture.d.ts +10 -0
  45. package/dist/ownership/store-fixture.d.ts.map +1 -0
  46. package/dist/proxmox/acl.d.ts +2 -2
  47. package/dist/proxmox/acl.d.ts.map +1 -1
  48. package/dist/proxmox/api-token-form.d.ts +2 -2
  49. package/dist/proxmox/api-token.d.ts +11 -11
  50. package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
  51. package/dist/proxmox/ceph-daemon.d.ts +19 -19
  52. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  53. package/dist/proxmox/ceph-flag.d.ts +5 -5
  54. package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
  55. package/dist/proxmox/ceph-fs.d.ts +16 -16
  56. package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
  57. package/dist/proxmox/ceph-osd.d.ts +5 -5
  58. package/dist/proxmox/ceph-pool-form.d.ts +13 -13
  59. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
  60. package/dist/proxmox/ceph-pool.d.ts +3 -3
  61. package/dist/proxmox/client.d.ts +2 -2
  62. package/dist/proxmox/credentials.d.ts +8 -8
  63. package/dist/proxmox/credentials.d.ts.map +1 -1
  64. package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
  65. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  66. package/dist/proxmox/firewall-alias-form.d.ts +1 -1
  67. package/dist/proxmox/firewall-alias.d.ts +1 -1
  68. package/dist/proxmox/generated/pbs.d.ts +1 -1
  69. package/dist/proxmox/generated/pve.d.ts +1 -1
  70. package/dist/proxmox/group.d.ts +8 -8
  71. package/dist/proxmox/ha-resource.d.ts +3 -2
  72. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  73. package/dist/proxmox/ha-rule-form.d.ts +5 -5
  74. package/dist/proxmox/ha-rule.d.ts +3 -3
  75. package/dist/proxmox/index.d.ts +6 -0
  76. package/dist/proxmox/index.d.ts.map +1 -1
  77. package/dist/proxmox/index.js +465 -217
  78. package/dist/proxmox/index.js.map +63 -58
  79. package/dist/proxmox/lease-cache.d.ts +4 -4
  80. package/dist/proxmox/lxc-adoption.d.ts +32 -0
  81. package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
  82. package/dist/proxmox/lxc-harness.d.ts +12 -0
  83. package/dist/proxmox/lxc-harness.d.ts.map +1 -1
  84. package/dist/proxmox/lxc-props.d.ts +2 -2
  85. package/dist/proxmox/lxc.d.ts +8 -8
  86. package/dist/proxmox/lxc.d.ts.map +1 -1
  87. package/dist/proxmox/members.d.ts +3 -3
  88. package/dist/proxmox/network-apply-read.d.ts +5 -5
  89. package/dist/proxmox/network-apply.d.ts +9 -9
  90. package/dist/proxmox/node-network-form.d.ts +6 -6
  91. package/dist/proxmox/node-network.d.ts +17 -17
  92. package/dist/proxmox/node-network.d.ts.map +1 -1
  93. package/dist/proxmox/notification-target.d.ts +3 -2
  94. package/dist/proxmox/notification-target.d.ts.map +1 -1
  95. package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
  96. package/dist/proxmox/pbs-datastore.d.ts +10 -10
  97. package/dist/proxmox/pbs-prune-job.d.ts +2 -2
  98. package/dist/proxmox/pbs-sync-job.d.ts +4 -4
  99. package/dist/proxmox/pbs-verify-job.d.ts +1 -1
  100. package/dist/proxmox/pool.d.ts +4 -4
  101. package/dist/proxmox/provision-baseline.d.ts +74 -0
  102. package/dist/proxmox/provision-baseline.d.ts.map +1 -0
  103. package/dist/proxmox/provision-bootstrap.d.ts +33 -0
  104. package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
  105. package/dist/proxmox/provision-cli-fake.d.ts +38 -0
  106. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
  107. package/dist/proxmox/provision-declare.d.ts +25 -0
  108. package/dist/proxmox/provision-declare.d.ts.map +1 -0
  109. package/dist/proxmox/replication-job.d.ts +9 -8
  110. package/dist/proxmox/replication-job.d.ts.map +1 -1
  111. package/dist/proxmox/resource.d.ts +2 -2
  112. package/dist/proxmox/resource.d.ts.map +1 -1
  113. package/dist/proxmox/sdn-apply-read.d.ts +7 -7
  114. package/dist/proxmox/sdn-apply.d.ts +1 -1
  115. package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
  116. package/dist/proxmox/sdn-subnet.d.ts +5 -5
  117. package/dist/proxmox/sdn-vnet.d.ts +5 -6
  118. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  119. package/dist/proxmox/storage.d.ts +4 -3
  120. package/dist/proxmox/storage.d.ts.map +1 -1
  121. package/dist/proxmox/user.d.ts +3 -2
  122. package/dist/proxmox/user.d.ts.map +1 -1
  123. package/dist/proxmox/values.d.ts +1 -1
  124. package/dist/proxmox/zfs-pool-write.d.ts +1 -1
  125. package/dist/proxmox/zfs-pool.d.ts +5 -5
  126. package/dist/talos/credentials.d.ts +1 -1
  127. package/dist/talos/credentials.d.ts.map +1 -1
  128. package/dist/talos/index.js.map +1 -1
  129. package/docs/adopt-verify.md +21 -5
  130. package/docs/adopted-deploys.md +5 -3
  131. package/docs/ownership.md +27 -2
  132. package/docs/provision-baseline.md +106 -0
  133. package/docs/proxmox-lxc-adopt.md +83 -0
  134. package/docs/proxmox-lxc.md +12 -49
  135. package/docs/proxmox.md +8 -0
  136. package/package.json +1 -1
  137. package/src/caddy/admin-guard.ts +1 -1
  138. package/src/caddy/local-admin.ts +1 -1
  139. package/src/cloudflare/client.ts +1 -1
  140. package/src/cloudflare/lock-rules.ts +1 -1
  141. package/src/forgejo/branch-protection-form.ts +1 -1
  142. package/src/forgejo/branch-protection.ts +1 -1
  143. package/src/forgejo/client.ts +1 -1
  144. package/src/forgejo/org-actions-secrets.ts +1 -1
  145. package/src/forgejo/org-label.ts +1 -1
  146. package/src/forgejo/org-team.ts +1 -1
  147. package/src/forgejo/repo-webhook.ts +1 -1
  148. package/src/forgejo/repository.ts +2 -2
  149. package/src/forgejo/resource.ts +2 -2
  150. package/src/forgejo/team-member.ts +1 -1
  151. package/src/openbao/README.md +4 -4
  152. package/src/openbao/auth-role.ts +1 -1
  153. package/src/openbao/bao-http.test.ts +2 -2
  154. package/src/openbao/bao-http.ts +6 -6
  155. package/src/openbao/bao-status.test.ts +1 -1
  156. package/src/openbao/bao-status.ts +1 -1
  157. package/src/openbao/cloudflare-group-scope.test.ts +1 -1
  158. package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
  159. package/src/openbao/cloudflare-permission-groups.ts +3 -3
  160. package/src/openbao/cloudflare-policy.ts +1 -1
  161. package/src/openbao/cloudflare-role-form.ts +2 -2
  162. package/src/openbao/cloudflare-roles-expand.ts +1 -1
  163. package/src/openbao/digest.ts +1 -1
  164. package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
  165. package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
  166. package/src/openbao/forgejo-bootstrap.ts +4 -4
  167. package/src/openbao/kubernetes-role-form.test.ts +5 -5
  168. package/src/openbao/mount.ts +1 -1
  169. package/src/openbao/pki-role.ts +1 -1
  170. package/src/openbao/policy-wire.test.ts +3 -3
  171. package/src/openbao/policy.ts +2 -2
  172. package/src/openbao/proxmox-role-form.ts +5 -5
  173. package/src/openbao/proxmox-role.ts +11 -11
  174. package/src/openbao/ssh-role.ts +3 -3
  175. package/src/ownership/adopting.test.ts +63 -0
  176. package/src/ownership/adopting.ts +46 -0
  177. package/src/ownership/ownership.test.ts +1 -34
  178. package/src/ownership/probe.ts +27 -14
  179. package/src/ownership/rows.ts +8 -2
  180. package/src/ownership/store-fixture.ts +50 -0
  181. package/src/proxmox/acl.ts +4 -4
  182. package/src/proxmox/adopt-noop.test.ts +6 -6
  183. package/src/proxmox/api-token-form.ts +4 -4
  184. package/src/proxmox/api-token.ts +13 -13
  185. package/src/proxmox/ceph-daemon-form.ts +3 -3
  186. package/src/proxmox/ceph-daemon.ts +20 -20
  187. package/src/proxmox/ceph-flag.ts +7 -7
  188. package/src/proxmox/ceph-fs-wire.ts +7 -7
  189. package/src/proxmox/ceph-fs.ts +18 -18
  190. package/src/proxmox/ceph-osd-tree.ts +16 -16
  191. package/src/proxmox/ceph-osd.ts +6 -6
  192. package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
  193. package/src/proxmox/ceph-pool-form.ts +13 -13
  194. package/src/proxmox/ceph-pool.ts +6 -6
  195. package/src/proxmox/client.test.ts +3 -3
  196. package/src/proxmox/client.ts +3 -3
  197. package/src/proxmox/credentials.test.ts +6 -8
  198. package/src/proxmox/credentials.ts +8 -8
  199. package/src/proxmox/fake-pve-lxc.ts +13 -1
  200. package/src/proxmox/firewall-alias-form.ts +1 -1
  201. package/src/proxmox/firewall-alias.ts +1 -1
  202. package/src/proxmox/generated/pbs.ts +1 -1
  203. package/src/proxmox/generated/pve.ts +1 -1
  204. package/src/proxmox/group.ts +10 -10
  205. package/src/proxmox/ha-resource.ts +3 -2
  206. package/src/proxmox/ha-rule-form.ts +5 -5
  207. package/src/proxmox/ha-rule.ts +3 -3
  208. package/src/proxmox/index.test.ts +7 -0
  209. package/src/proxmox/index.ts +14 -0
  210. package/src/proxmox/lease-cache.test.ts +19 -19
  211. package/src/proxmox/lease-cache.ts +4 -4
  212. package/src/proxmox/lxc-adopt.test.ts +16 -23
  213. package/src/proxmox/lxc-adoption.ts +50 -0
  214. package/src/proxmox/lxc-create-form.ts +1 -1
  215. package/src/proxmox/lxc-guards.test.ts +10 -17
  216. package/src/proxmox/lxc-harness.ts +27 -4
  217. package/src/proxmox/lxc-judge.test.ts +5 -5
  218. package/src/proxmox/lxc-lifecycle.ts +1 -1
  219. package/src/proxmox/lxc-ownership.test.ts +19 -14
  220. package/src/proxmox/lxc-props.ts +2 -2
  221. package/src/proxmox/lxc-read.test.ts +3 -3
  222. package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
  223. package/src/proxmox/lxc-volume.test.ts +9 -9
  224. package/src/proxmox/lxc-volume.ts +1 -1
  225. package/src/proxmox/lxc-wire.ts +1 -1
  226. package/src/proxmox/lxc.ts +19 -15
  227. package/src/proxmox/members.test.ts +1 -1
  228. package/src/proxmox/members.ts +3 -3
  229. package/src/proxmox/metric-server-otel.ts +2 -2
  230. package/src/proxmox/network-apply-read.ts +10 -10
  231. package/src/proxmox/network-apply.ts +10 -10
  232. package/src/proxmox/node-network-form.ts +7 -7
  233. package/src/proxmox/node-network.ts +18 -18
  234. package/src/proxmox/notification-target.ts +3 -2
  235. package/src/proxmox/pbs-datastore-guard.ts +1 -1
  236. package/src/proxmox/pbs-datastore.ts +10 -10
  237. package/src/proxmox/pbs-prune-job.ts +2 -2
  238. package/src/proxmox/pbs-sync-job.ts +4 -4
  239. package/src/proxmox/pbs-verify-job.ts +1 -1
  240. package/src/proxmox/pool.test.ts +2 -2
  241. package/src/proxmox/pool.ts +4 -4
  242. package/src/proxmox/provision-baseline.test.ts +87 -0
  243. package/src/proxmox/provision-baseline.ts +162 -0
  244. package/src/proxmox/provision-bootstrap.test.ts +132 -0
  245. package/src/proxmox/provision-bootstrap.ts +135 -0
  246. package/src/proxmox/provision-cli-fake.ts +217 -0
  247. package/src/proxmox/provision-declare.test.ts +111 -0
  248. package/src/proxmox/provision-declare.ts +103 -0
  249. package/src/proxmox/replication-job.ts +9 -8
  250. package/src/proxmox/resource.ts +3 -3
  251. package/src/proxmox/sdn-apply-read.test.ts +16 -16
  252. package/src/proxmox/sdn-apply-read.ts +7 -7
  253. package/src/proxmox/sdn-apply.ts +3 -3
  254. package/src/proxmox/sdn-subnet-form.ts +1 -1
  255. package/src/proxmox/sdn-subnet.ts +5 -5
  256. package/src/proxmox/sdn-vnet.ts +5 -6
  257. package/src/proxmox/storage.ts +4 -3
  258. package/src/proxmox/user.ts +3 -2
  259. package/src/proxmox/values.test.ts +2 -2
  260. package/src/proxmox/values.ts +1 -1
  261. package/src/proxmox/zfs-pool-write.ts +1 -1
  262. package/src/proxmox/zfs-pool.ts +6 -6
  263. package/src/talos/credentials.ts +3 -3
  264. package/src/talos/values.test.ts +9 -9
  265. package/src/verify/cli.test.ts +1 -1
@@ -14,14 +14,14 @@
14
14
  *
15
15
  * * ⛔ THE LAST SEVEN ARE REPORTED AND NEVER COMPARED, AND EACH ONE IS A MEASURED FOREVER-DIFF.
16
16
  * `priority` is assigned by PVE FROM FILE ORDER — `my $priority = 2; ... $d->{priority} =
17
- * $priority++` — and it is not a POST or PUT parameter at all. MEASURED ACROSS TB4: vmbr1.11 is
18
- * priority 16 on n2 and n3 and 15 on n4, because n4 has no `wlp91s0` above it. One declaration
17
+ * $priority++` — and it is not a POST or PUT parameter at all. MEASURED ACROSS C1: vmbr1.42 is
18
+ * priority 16 on node-b and node-c and 15 on node-d, because node-d has no `wlan0` above it. One declaration
19
19
  * reused across three nodes would diff on exactly one of them, forever, over nothing.
20
20
  * `method` is RECOMPUTED on every write from whether the form carried an address, `families` is
21
21
  * recomputed the same way and never written to the file, `active` and `exists` describe the
22
22
  * kernel rather than the config, and `bond_miimon`, `bridge_stp` and `bridge_fd` are emitted by
23
23
  * PVE's writer with defaults (100, `off`, 0) while appearing in NEITHER write schema —
24
- * `additionalProperties => 0` means sending one is a 400. Live proof of all three: n2's bond0
24
+ * `additionalProperties => 0` means sending one is a 400. Live proof of all three: node-b's bond0
25
25
  * returns `bond_miimon "100"` and vmbr1 returns `bridge_stp "off"`, `bridge_fd "0"`, none of
26
26
  * which any declaration can set.
27
27
  */
@@ -74,7 +74,7 @@ export const ifaceList = (value: unknown) =>
74
74
  * `ceph transport` comes back as `ceph transport\n` and the two never compare equal.
75
75
  * ⚠️ AND THE READER FOLDS `comments6` INTO `comments` (`$d->{comments} .= $comments6`), so on an
76
76
  * interface that carries an IPv6 comment a declared `comments` CANNOT match what comes back.
77
- * TB4 has none; on an interface that does, leave `comments` undeclared rather than fighting it.
77
+ * C1 has none; on an interface that does, leave `comments` undeclared rather than fighting it.
78
78
  */
79
79
  export const comment = (value: unknown) => text(value).replace(/\s+$/, '');
80
80
 
@@ -111,7 +111,7 @@ const field = (name: string, value: string | undefined): Record<string, string>
111
111
  *
112
112
  * ⚠️ `netmask` IS A WRITABLE PARAMETER AND IS DELIBERATELY NOT OFFERED. PVE raises
113
113
  * "netmask conflicts with cidr" when both are sent, and the reader rewrites whatever was written
114
- * into prefix form — MEASURED: n2's vmbr1.11 reports `"netmask":"24"`, never `255.255.255.0`.
114
+ * into prefix form — MEASURED: node-b's vmbr1.42 reports `"netmask":"24"`, never `255.255.255.0`.
115
115
  * A resource accepting both spellings would let a declaration diff against its own value.
116
116
  */
117
117
  export const body = (props: NodeNetworkProps): Record<string, string> => ({
@@ -144,7 +144,7 @@ export const body = (props: NodeNetworkProps): Record<string, string> => ({
144
144
  * `ifreload -a` I did NOT measure. Clearing cidr explicitly at least produces a clean, honest
145
145
  * manual interface — and `matches` compares `cidr` UNCONDITIONALLY, so the plan says so first.
146
146
  *
147
- * ⛔ ON vmbr1.11 THAT IS THE CEPH TRANSPORT. A declaration of that interface without its `cidr` is
147
+ * ⛔ ON vmbr1.42 THAT IS THE CEPH TRANSPORT. A declaration of that interface without its `cidr` is
148
148
  * a declaration that it should have no address, applied across three nodes. The plan will read
149
149
  * `1 to update` rather than `noop`; do not wave it through.
150
150
  *
@@ -177,7 +177,7 @@ export const readAttributes = (
177
177
  * same call metric-server.ts makes, for a worse reason. `type` is required on the PUT and
178
178
  * the PUT MERGES, so a declaration naming `bridge` for what is really a vlan would write
179
179
  * `type bridge` into the stanza and PVE's writer would then emit bridge-ports and
180
- * bridge-stp lines for vmbr1.11. Reporting absent instead makes reconcile POST a create,
180
+ * bridge-stp lines for vmbr1.42. Reporting absent instead makes reconcile POST a create,
181
181
  * which PVE refuses with "interface already exists": loud, and it changes nothing.
182
182
  */
183
183
  const liveType = text(live['type']);
@@ -11,23 +11,23 @@
11
11
  * that apply — this one never calls it, exactly as `Proxmox.SdnZone` never calls
12
12
  * `Proxmox.SdnApply`.
13
13
  *
14
- * ⛔ CEPH ON TB4 RIDES TWO NETWORKS AND THIS API ONLY SEES ONE OF THEM. Measured from
14
+ * ⛔ CEPH ON C1 RIDES TWO NETWORKS AND THIS API ONLY SEES ONE OF THEM. Measured from
15
15
  * `/etc/pve/ceph.conf` on 2026-09-13:
16
16
  *
17
- * public_network = 10.20.11.0/24 -> vmbr1.11, which this resource DOES see
18
- * cluster_network = 10.100.0.0/24 -> every OSD's cluster_addr: .102 / .103 / .104
17
+ * public_network = 198.51.100.0/24 -> vmbr1.42, which this resource DOES see
18
+ * cluster_network = 203.0.113.0/24 -> every OSD's cluster_addr: .102 / .103 / .104
19
19
  *
20
20
  * The cluster network — OSD REPLICATION, the traffic that rebuilds a lost replica — lives on
21
- * `10.100.0.10X/32` addresses carried by `en05`, `en06` and `dummy_tb4`: the Thunderbolt mesh.
21
+ * `203.0.113.10X/32` addresses carried by `tb0`, `tb1` and `dummy_c1`: the Thunderbolt mesh.
22
22
  *
23
23
  * ⛔ AND THOSE ADDRESSES ARE INVISIBLE HERE, WHICH IS THE TRAP. They are declared in
24
24
  * `/etc/network/interfaces.d/sdn`, and PVE's network API does not parse that directory.
25
- * MEASURED: `GET /nodes/n2/network/en05` answers `{"method":"manual","type":"eth",…}` with NO
26
- * address, while `ip -br -4 addr` shows `en05 UP 10.100.0.102/32`. So this resource reports
27
- * en05 and en06 as address-less manual ports and will plan `noop` over them — implicitly
25
+ * MEASURED: `GET /nodes/node-b/network/tb0` answers `{"method":"manual","type":"eth",…}` with NO
26
+ * address, while `ip -br -4 addr` shows `tb0 UP 203.0.113.102/32`. So this resource reports
27
+ * tb0 and tb1 as address-less manual ports and will plan `noop` over them — implicitly
28
28
  * asserting they carry no address, which is FALSE.
29
29
  *
30
- * The consequence, stated plainly: declaring en05/en06 from what this API reports and then
30
+ * The consequence, stated plainly: declaring tb0/tb1 from what this API reports and then
31
31
  * DESTROYING or applying over them takes out OSD replication on a cluster whose pools are
32
32
  * size 3 / min_size 2. They read as the most boring interfaces on the node and are the two most
33
33
  * dangerous. Leave them undeclared; if they must be managed, manage the file that actually
@@ -55,7 +55,7 @@
55
55
  * interface a node already has would otherwise leave it holding a pending network change.
56
56
  *
57
57
  * ⚠️ WHAT IS STILL TRUE: when `matches` is false the PUT rewrites the whole stanza, and that can
58
- * ADD lines the file did not have — a `vlan-raw-device vmbr1` under `vmbr1.11`, where PVE had
58
+ * ADD lines the file did not have — a `vlan-raw-device vmbr1` under `vmbr1.42`, where PVE had
59
59
  * been deriving it from the name. Harmless in content, and it costs nothing until somebody
60
60
  * applies; one more reason the apply is a separate resource.
61
61
  *
@@ -79,10 +79,10 @@ import {
79
79
  import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
80
80
 
81
81
  /**
82
- * PVE's interface types. `unknown` is what a NIC it does not manage reports — n2 and n3 both
83
- * report `wlp91s0` that way, and n4 has no such interface at all.
82
+ * PVE's interface types. `unknown` is what a NIC it does not manage reports — node-b and node-c both
83
+ * report `wlan0` that way, and node-d has no such interface at all.
84
84
  *
85
- * ⚠️ THAT ASYMMETRY IS A TRAP FOR A DECLARATION SHARED ACROSS NODES. On n4 the read answers 404,
85
+ * ⚠️ THAT ASYMMETRY IS A TRAP FOR A DECLARATION SHARED ACROSS NODES. On node-d the read answers 404,
86
86
  * which this provider cannot tell from "deleted", so reconcile POSTs — writing a stanza for a
87
87
  * card that is not in the machine. Declare per node what each node actually has; a physical
88
88
  * interface is discovered, not decided.
@@ -104,24 +104,24 @@ export type NodeNetworkType =
104
104
  export interface NodeNetworkProps extends WithTarget {
105
105
  /** Which node's file this stanza lives in. Interfaces are per node, never cluster-wide. */
106
106
  node: string;
107
- /** `vmbr0`, `bond0`, `vmbr1.11`. 2-20 characters, PVE's `pve-iface` format. */
107
+ /** `vmbr0`, `bond0`, `vmbr1.42`. 2-20 characters, PVE's `pve-iface` format. */
108
108
  iface: string;
109
109
  /** ⛔ REQUIRED ON EVERY WRITE, update included, never used to retype — see `readAttributes`. */
110
110
  type: NodeNetworkType;
111
111
  /**
112
- * `10.20.11.12/24`. ⛔ THE ONE FIELD WITH NO UNMANAGED MODE: leaving it out is an instruction
112
+ * `198.51.100.12/24`. ⛔ THE ONE FIELD WITH NO UNMANAGED MODE: leaving it out is an instruction
113
113
  * to make the interface `manual`, not an instruction to leave its address alone. The ⛔ on
114
114
  * `updateBody` in node-network-form.ts has the measurement and the consequence.
115
115
  */
116
116
  cidr?: string;
117
117
  /** ⚠️ PVE allows exactly ONE default gateway per node and refuses a second with "Default
118
- * gateway already exists on interface '<other>'". On TB4 it is vmbr0.10's. */
118
+ * gateway already exists on interface '<other>'". On C1 it is vmbr0.41's. */
119
119
  gateway?: string;
120
120
  /**
121
121
  * `auto <iface>` in the file. Absent on read means off, which is why `bool`'s fallback is used.
122
122
  * ⚠️ UNDECLARED IS UNMANAGED ON AN UPDATE AND OFF ON A CREATE — the same asymmetry storage.ts
123
123
  * has, and it bites harder here: an interface created without `autostart` is one the node
124
- * will not bring up at boot. Every TB4 bridge, bond and vlan carries it.
124
+ * will not bring up at boot. Every C1 bridge, bond and vlan carries it.
125
125
  */
126
126
  autostart?: boolean;
127
127
  /** 1280-65520. Unset leaves the file without an `mtu` line and the kernel default in force. */
@@ -145,7 +145,7 @@ export interface NodeNetworkProps extends WithTarget {
145
145
  bond_xmit_hash_policy?: string;
146
146
  /** active-backup only. Kept hyphenated because that is the wire name. */
147
147
  'bond-primary'?: string;
148
- /** ⚠️ DERIVED FROM A DOTTED NAME. `vmbr1.11` reports `vlan-id` 11 with no such line in the file;
148
+ /** ⚠️ DERIVED FROM A DOTTED NAME. `vmbr1.42` reports `vlan-id` 42 with no such line in the file;
149
149
  * declaring it there is harmless but adds a line the file did not have. */
150
150
  'vlan-id'?: number;
151
151
  'vlan-raw-device'?: string;
@@ -209,7 +209,7 @@ const handlers = pveHandlers<NodeNetworkProps, NodeNetworkAttributes>({
209
209
  * omission is an edit. Comparing it unconditionally is what makes that edit visible in `plan`
210
210
  * instead of at `ifreload` time.
211
211
  *
212
- * ⚠️ NOTHING IN THE REPORTED-ONLY BLOCK IS HERE, and that is what makes the TB4 declaration
212
+ * ⚠️ NOTHING IN THE REPORTED-ONLY BLOCK IS HERE, and that is what makes the C1 declaration
213
213
  * plan as `noop` on all three nodes at once — the ⛔ above `NodeNetworkAttributes` lists them
214
214
  * and the measurement behind each.
215
215
  */
@@ -39,8 +39,9 @@
39
39
  * reconcile POST + PUT Mapping.Modify on /mapping/notifications AND one of
40
40
  * Sys.Audit / Sys.Modify / Sys.AccessNetwork on /
41
41
  * delete DELETE Mapping.Modify on /mapping/notifications
42
- * `LXCProvisioner` already carries Sys.Audit on `/` (see lxc.ts), so the delta is the two Mapping
43
- * privileges — notably NOT the `Sys.Modify` a backup job costs.
42
+ * The provision role already carried Sys.Audit on `/` (see lxc.ts), so the delta was the two
43
+ * Mapping privileges — notably NOT the `Sys.Modify` a backup job costs. `PROVISION_PRIVILEGES`
44
+ * carries both on `/`: the baseline took the trade below knowingly.
44
45
  * ⚠️ PREFER A SECOND ROLE GRANTED AT `/mapping/notifications` OVER WIDENING THE PROVISIONING ROLE
45
46
  * ON `/`. `/mapping` also holds the PCI and USB passthrough maps, so Mapping.Modify at the root
46
47
  * lets a credential scoped to make containers rewire somebody's hardware.
@@ -54,7 +54,7 @@ export const guardPath = (live: PbsDatastoreAttributes, props: PbsDatastoreProps
54
54
  *
55
55
  * ⚠️ AN UNDECLARED `backend` IS NOT A DIVERGENCE. Undeclared is unmanaged everywhere in this
56
56
  * family, and this guard keeps that rule: it fires only when the declaration says something and
57
- * PBS says something else. Adopting `r2-offsite` without mentioning `backend` still plans noop.
57
+ * PBS says something else. Adopting `offsite` without mentioning `backend` still plans noop.
58
58
  *
59
59
  * ⚠️ COMPARED AS A CANONICAL PROPERTY STRING, so `bucket=x,type=s3` and `type=s3,bucket=x` are the
60
60
  * same backend and key order cannot deadlock a plan — the reason `samePath` trims a slash.
@@ -10,7 +10,7 @@
10
10
  * `{"data":{"pong":true}}` against a Let's Encrypt certificate for CN=pbs.example.com;
11
11
  * `/version` and `/config/datastore` answer 401 "authentication failed";
12
12
  * `pbs.mgmt.example.com:8007` answers 401 and its certificate verifies too; and
13
- * `/access/domains` lists the realms `pbs`, `pam` and an openid realm `Schenanigans`. So the
13
+ * `/access/domains` lists the realms `pbs`, `pam` and an openid realm. So the
14
14
  * prefix, the `{"data": …}` envelope and the strict-TLS story are PVE's exactly — and only the
15
15
  * credential differs. There is no `pve` realm here; a PVE token cannot authenticate at all.
16
16
  * The header differs too, which is why `pve()` exists rather than `pve()` being reused — see the
@@ -18,18 +18,18 @@
18
18
  *
19
19
  * ⛔ THERE IS NO OPENBAO MOUNT FOR PBS TODAY, SO NOTHING HERE CAN DEPLOY YET. `PbsTarget.mount` is
20
20
  * a parameter for the reason `PveTarget.mount` is — the provider must be able to leave this
21
- * estate — but the estate has nothing to put in it. MEASURED: under the `claude-code` approle,
22
- * `bao token capabilities` answers `deny` for `proxmox-tb4/creds/{read,provision}`,
23
- * `proxmox-ops/creds/read` and every `…pbs…` path tried, and `sys/mounts` is 403 — so I could
21
+ * estate — but the estate has nothing to put in it. MEASURED: under the agent's approle,
22
+ * `bao token capabilities` answers `deny` for `proxmox-c1/creds/{read,provision}`,
23
+ * `proxmox-c2/creds/read` and every `…pbs…` path tried, and `sys/mounts` is 403 — so I could
24
24
  * not enumerate the mounts to PROVE a PBS one is absent. That it is absent is the brief's
25
25
  * statement, not my measurement.
26
26
  * ★ WHAT THE MOUNT MUST VEND, so `mint()` is reused unchanged: `bao read -format=json
27
27
  * <mount>/creds/<role>` answering `{"data":{"token_id":…,"secret":…}}`, where `token_id` is a
28
28
  * PBS token id `user@realm!tokenname` — `hf-read@pbs!…` and `hf-provision@pbs!…`. Same short,
29
- * non-renewable lease contract as `proxmox-tb4`.
29
+ * non-renewable lease contract as `proxmox-c1`.
30
30
  * ⚠️ PBS HAS NO DYNAMIC-SECRETS PLUGIN OF ITS OWN — OpenBao's Proxmox support is a PVE thing — so
31
31
  * the mount is a small custom vendor against `POST /access/users/{id}/token/{name}`. Say which
32
- * mount and role you needed rather than reaching for the read-only `monitoring@pbs` token on
32
+ * mount and role you needed rather than reaching for the read-only `metrics@pbs` token on
33
33
  * the kv shelf: widening that would delete the outer lock for every reader of the shelf.
34
34
  *
35
35
  * ⛔ A DATASTORE HOLDS THE BACKUPS, SO `retain` IS THE DEFAULT AND `delete` IS FULLY IMPLEMENTED —
@@ -47,7 +47,7 @@
47
47
  * ⛔ NO SECRET IS A PROP OR AN ATTRIBUTE, AND THIS FAMILY IS CLEAN BY CONSTRUCTION: a datastore
48
48
  * section holds no password, token or key — encryption keys belong to the backup CLIENT. Every
49
49
  * field below is a name, a path, a schedule, a count or a policy string, all of which are safe in
50
- * a state store Alchemy writes UNENCRYPTED and this estate dumps to CT100 nightly.
50
+ * a state store Alchemy writes UNENCRYPTED and this estate copies to a backup guest nightly.
51
51
  */
52
52
  import { Resource } from 'alchemy';
53
53
  import { isResolved } from 'alchemy/Diff';
@@ -131,11 +131,11 @@ export interface PbsDatastoreProps {
131
131
  * `datastore create --help` offers `--backend`, `datastore update --help` does NOT, and
132
132
  * `backend` is absent from update's `--delete` enum. PBS will not move a datastore between
133
133
  * local disk and object storage, ever.
134
- * ★ IT IS A PROP AT ALL BECAUSE THE ESTATE HAS ONE. `r2-offsite` holds
135
- * `type=s3,client=cloudflare-r2,bucket=homeflare-pbs`. Leaving it undeclared would still plan
134
+ * ★ IT IS A PROP AT ALL BECAUSE THE ESTATE HAS ONE. `offsite` holds
135
+ * `type=s3,client=cloudflare-r2,bucket=example-pbs`. Leaving it undeclared would still plan
136
136
  * noop — undeclared is unmanaged here — but the declaration would then describe a LOCAL
137
137
  * datastore, and anyone recreating from it would get exactly that: an empty directory on the
138
- * mini's disk where the offsite copy used to be, behind a green plan the whole way.
138
+ * host's disk where the offsite copy used to be, behind a green plan the whole way.
139
139
  * ⚠️ A property string. Compared canonically, so key order is never a diff.
140
140
  */
141
141
  backend?: string;
@@ -37,10 +37,10 @@
37
37
  * answers 401 "authentication failure", which reads as a bad credential rather than as a
38
38
  * malformed header — the same trap credentials.ts already records for PVE's colon form.
39
39
  * ⛔ AND THERE IS NO MOUNT TO MINT FROM. `mint()` runs `bao read <mount>/creds/<role>`; no
40
- * OpenBao mount vends PBS tokens — `proxmox-tb4` and `proxmox-ops` vend PVE ones. The
40
+ * OpenBao mount vends PBS tokens — `proxmox-c1` and `proxmox-c2` vend PVE ones. The
41
41
  * `kv/infra/proxmox` shelf holds a READ-ONLY `Audit` token for PBS, and a read-only token
42
42
  * cannot create a prune job. I could not confirm that shelf's contents: this machine's
43
- * claude-code approle answers 403 on `sys/mounts` AND on `kv/metadata/infra/proxmox`
43
+ * agent approle answers 403 on `sys/mounts` AND on `kv/metadata/infra/proxmox`
44
44
  * (MEASURED). What is needed is a dynamic PBS mount vending a `provision` role with
45
45
  * `Datastore.Modify` — say that, rather than reaching for the stored auditor token.
46
46
  *
@@ -12,14 +12,14 @@
12
12
  *
13
13
  * ⛔ THE CREDENTIAL IS A PBS TOKEN AND IT DOES NOT EXIST YET; NO MOUNT NAME IS INVENTED IN THIS
14
14
  * FILE. PBS wants `user@realm!tokenname` plus a UUID secret, issued by PBS — a PVE token is not
15
- * one, whatever it is spelled like. MEASURED from this machine on 2026-09-13: the `claude-code`
15
+ * one, whatever it is spelled like. MEASURED from this machine on 2026-09-13: the agent's
16
16
  * approle cannot enumerate mounts (`bao secrets list` -> 403) and
17
17
  * `bao token capabilities kv/infra/proxmox/pbs` -> `deny`, so I could not confirm from here what
18
18
  * does or does not exist. What credentials.ts records is that every PBS token on the kv shelf is
19
- * READ-ONLY (`Audit`, as `monitoring@pve`) and STATIC — too narrow to create a sync job, and
19
+ * READ-ONLY (`Audit`, as `metrics@pve`) and STATIC — too narrow to create a sync job, and
20
20
  * exactly the kind of credential this provider exists to avoid writing with.
21
21
  * ★ SO THE MOUNT IS A `PbsTarget.mount` THE STACK SUPPLIES, and somebody has to build it first:
22
- * a dynamic PBS mount with `read` and `provision` roles mirroring `proxmox-tb4`. Until it
22
+ * a dynamic PBS mount with `read` and `provision` roles mirroring `proxmox-c1`. Until it
23
23
  * exists this family can be declared and cannot be deployed. `permission denied` means the
24
24
  * grant is missing — say which mount and role you needed; do not fall back to the shelf token.
25
25
  *
@@ -83,7 +83,7 @@ export interface SyncJobProps extends WithPbsTarget {
83
83
  * `Pbs.Remote` family is OUT OF SCOPE for this package rather than merely unwritten:
84
84
  * `POST /config/remote` requires `password`, and Alchemy persists props and attributes to its
85
85
  * state store UNENCRYPTED (StateEncoding.ts tags Redacted values rather than encrypting them),
86
- * which here is the `alchemy` Postgres that pg-backup.sh dumps nightly to CT100. A remote's
86
+ * which here is the `alchemy` Postgres that a nightly job dumps to a backup guest. A remote's
87
87
  * password in a prop is that password in four places, for months. The only Remote resource that
88
88
  * could exist would refuse `password` the way storage.ts refuses it — as a `never` — and could
89
89
  * therefore never CREATE a remote, which is a resource that cannot do what its name promises.
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * ⛔ THIS FILE CANNOT AUTHENTICATE UNTIL TWO THINGS OUTSIDE IT CHANGE, AND IT IS SHIPPED SAYING SO
13
13
  * RATHER THAN SHIPPED LOOKING FINISHED. Both are recorded on `PbsTarget` below:
14
- * 1. `credentials.ts` has no PBS mint. MEASURED 2026-09-13 from this Mac: the `claude-code`
14
+ * 1. `credentials.ts` has no PBS mint. MEASURED 2026-09-13 from this Mac: the agent's
15
15
  * approle is refused `sys/mounts`, `kv/infra/proxmox` and `proxmox-pbs/creds/read` alike —
16
16
  * 403 permission denied on all three — so there is no mount to name and none is invented.
17
17
  * 2. `client.ts`'s `authorization()` builds a PVE header, and PBS will not accept it. See the
@@ -17,14 +17,14 @@ const target: PoolProps['target'] = {
17
17
 
18
18
  const props = (over: Partial<PoolProps> = {}): PoolProps => ({
19
19
  target,
20
- poolid: 'house',
20
+ poolid: 'lab',
21
21
  ...over,
22
22
  });
23
23
 
24
24
  describe('pool forms match generated POST/PUT parameters', () => {
25
25
  it('createForm sends exactly poolid and comment', () => {
26
26
  const form: PoolsPostParams = poolCreateForm(props({ comment: 'estate' }));
27
- assert.deepEqual(form, { poolid: 'house', comment: 'estate' });
27
+ assert.deepEqual(form, { poolid: 'lab', comment: 'estate' });
28
28
  assert.deepEqual(Object.keys(form).sort(), ['comment', 'poolid']);
29
29
  });
30
30
 
@@ -8,11 +8,11 @@
8
8
  * its own state would have reported noop straight past a hand-edit in the UI.
9
9
  *
10
10
  * ⚠️ IT 403'd UNTIL THE ROLE WAS WIDENED, AND THE HISTORY IS WORTH KEEPING. `hf-provision@pve`
11
- * held `LXCProvisioner` on `/` with no `Pool.Allocate`, so reconcile answered
12
- * "Permission check failed (/pool/house, Pool.Allocate)". The credential could not widen itself
11
+ * held the provision role on `/` with no `Pool.Allocate`, so reconcile answered
12
+ * "Permission check failed (/pool/lab, Pool.Allocate)". The credential could not widen itself
13
13
  * — `PUT /access/roles` is 403 for it too — so the role was extended over SSH with
14
- * `pveum role modify LXCProvisioner --privs "<existing>,Pool.Allocate,Pool.Audit"`, preserving
15
- * every existing privilege. Each new resource in this package should state the privileges its
14
+ * `pveum role modify <role> --privs "<existing>,Pool.Allocate,Pool.Audit"`, preserving every
15
+ * existing privilege. `PROVISION_PRIVILEGES` (provision-baseline.ts) carries both now. Each new resource in this package should state the privileges its
16
16
  * reconcile needs, so widening stays a deliberate act rather than a reaction to a 403.
17
17
  */
18
18
  import { Resource } from 'alchemy';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The baseline itself: the one privilege list, the generic defaults, and the names both halves
3
+ * refuse before either can paste them into a root shell or a declaration.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import {
7
+ PROVISION_DEFAULTS,
8
+ PROVISION_PRIVILEGES,
9
+ provisionBaseline,
10
+ } from './provision-baseline.ts';
11
+
12
+ describe('PROVISION_PRIVILEGES', () => {
13
+ test('is 27 privileges, sorted, unique and frozen', () => {
14
+ expect(PROVISION_PRIVILEGES).toHaveLength(27);
15
+ expect([...PROVISION_PRIVILEGES].sort()).toEqual([...PROVISION_PRIVILEGES]);
16
+ expect(new Set(PROVISION_PRIVILEGES).size).toBe(27);
17
+ expect(Object.isFrozen(PROVISION_PRIVILEGES)).toBe(true);
18
+ });
19
+
20
+ test('holds what the lane needs to repair its own baseline (role.ts: no lock-out)', () => {
21
+ for (const needed of [
22
+ 'Sys.Modify', // the role
23
+ 'Realm.AllocateUser', // the users
24
+ 'User.Modify',
25
+ 'Group.Allocate', // the mint group
26
+ 'Permissions.Modify', // the grants, and reading them at all (acl.ts)
27
+ ]) {
28
+ expect(PROVISION_PRIVILEGES).toContain(needed);
29
+ }
30
+ });
31
+ });
32
+
33
+ describe('provisionBaseline', () => {
34
+ test('defaults: one role, one mint group, two lanes bound on /', () => {
35
+ const baseline = provisionBaseline();
36
+ expect(baseline.role).toEqual({ privs: PROVISION_PRIVILEGES, roleid: 'HfProvisioner' });
37
+ expect(baseline.group).toEqual({ comment: PROVISION_DEFAULTS.comment, groupid: 'hf-mint' });
38
+ expect(baseline.users.map((u) => [u.lane, u.userid, u.groups])).toEqual([
39
+ ['provision', 'hf-provision@pve', ['hf-mint']],
40
+ ['read', 'hf-read@pve', ['hf-mint']],
41
+ ]);
42
+ expect(baseline.grants).toEqual([
43
+ { lane: 'provision', roleid: 'HfProvisioner', userid: 'hf-provision@pve' },
44
+ { lane: 'read', roleid: 'PVEAuditor', userid: 'hf-read@pve' },
45
+ ]);
46
+ });
47
+
48
+ test('a site passes its own names; an explicit undefined is the default', () => {
49
+ const baseline = provisionBaseline({
50
+ mintGroup: 'mint',
51
+ provisionUser: 'provisioner@pve',
52
+ readRole: 'Auditor',
53
+ readUser: 'auditor@pve',
54
+ role: undefined,
55
+ });
56
+ expect(baseline.role.roleid).toBe('HfProvisioner');
57
+ expect(baseline.grants.map((g) => `${g.userid} ${g.roleid}`)).toEqual([
58
+ 'provisioner@pve HfProvisioner',
59
+ 'auditor@pve Auditor',
60
+ ]);
61
+ expect(baseline.users.every((u) => u.groups.join() === 'mint')).toBe(true);
62
+ });
63
+
64
+ test('readUser null is a baseline with no read lane', () => {
65
+ const baseline = provisionBaseline({ readUser: null });
66
+ expect(baseline.users.map((u) => u.lane)).toEqual(['provision']);
67
+ expect(baseline.grants.map((g) => g.lane)).toEqual(['provision']);
68
+ });
69
+
70
+ test('refuses, all at once, every name that would need quoting or is not PVE-shaped', () => {
71
+ const attempt = () =>
72
+ provisionBaseline({
73
+ comment: "it's",
74
+ mintGroup: 'a b',
75
+ provisionUser: 'no-realm',
76
+ readRole: 'x;rm',
77
+ readUser: 'q{}@pve',
78
+ role: '$(id)',
79
+ });
80
+ expect(attempt).toThrow(/role `\$\(id\)`.*mintGroup `a b`.*readRole `x;rm`/);
81
+ expect(attempt).toThrow(/provisionUser `no-realm`.*readUser `q\{\}@pve`.*comment/);
82
+ });
83
+
84
+ test('refuses one user for both lanes', () => {
85
+ expect(() => provisionBaseline({ readUser: 'hf-provision@pve' })).toThrow(/one user/);
86
+ });
87
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The provisioning baseline: the access every PVE cluster and node needs before a stack built on
3
+ * this package can manage it — one role, the users OpenBao mints tokens for, the group that fences
4
+ * the mint, and the grants that bind them. ONE description, read by both halves:
5
+ * · provision-declare.ts turns it into `Proxmox.Role`/`Group`/`User`/`Acl` resources, so a stack
6
+ * adopts the baseline and keeps it (drift is a plan, not a 403 weeks later);
7
+ * · provision-bootstrap.ts turns it into the one-time root commands for a NEW cluster or node.
8
+ * The two cannot drift apart because neither holds a name or a privilege of its own.
9
+ *
10
+ * ⛔ THE PROVISION LANE CANNOT CREATE ITSELF. Every write here runs as a token OpenBao mints for the
11
+ * provision user, and minting needs that user to exist, in the mint group, bound to this role.
12
+ * So a new cluster is bootstrapped ONCE by root@pam with the generated commands, and the stack
13
+ * then adopts what they made (docs/provision-baseline.md).
14
+ *
15
+ * ★ ONE PRIVILEGE LIST FOR EVERY FAMILY, NOT A ROLE PER RESOURCE. The list is what the families'
16
+ * reconciles need, unioned — the set a hand-kept provision role reached one 403 at a time, each
17
+ * step recorded where it was hit (pool.ts, sdn-vnet.ts, ha-rule.ts). It includes what the lane needs
18
+ * to manage THIS baseline (`Sys.Modify` for the role, `Realm.AllocateUser`/`User.Modify` for the
19
+ * users, `Group.Allocate`, `Permissions.Modify` for the grants), so declaring it can never narrow
20
+ * the lane out of its own repair (role.ts: "A ROLE CAN LOCK ITS OWN PROVIDER OUT").
21
+ * ⚠️ NOT EVERY FAMILY: root@pam-only writes (device passthrough, bind mounts — lxc-judge.ts) and
22
+ * privileges a family names as still missing (`VM.Replicate`, replication-job.ts) are outside it.
23
+ */
24
+
25
+ /** The provision role's privileges, sorted — the exact set, never an addition to a live one. */
26
+ export const PROVISION_PRIVILEGES: readonly string[] = Object.freeze([
27
+ 'Datastore.Allocate',
28
+ 'Datastore.AllocateSpace',
29
+ 'Datastore.Audit',
30
+ 'Group.Allocate',
31
+ 'Mapping.Audit',
32
+ 'Mapping.Modify',
33
+ 'Permissions.Modify',
34
+ 'Pool.Allocate',
35
+ 'Pool.Audit',
36
+ 'Realm.AllocateUser',
37
+ 'SDN.Allocate',
38
+ 'SDN.Audit',
39
+ 'SDN.Use',
40
+ 'Sys.Audit',
41
+ 'Sys.Console',
42
+ 'Sys.Modify',
43
+ 'User.Modify',
44
+ 'VM.Allocate',
45
+ 'VM.Audit',
46
+ 'VM.Backup',
47
+ 'VM.Config.CPU',
48
+ 'VM.Config.Disk',
49
+ 'VM.Config.HWType',
50
+ 'VM.Config.Memory',
51
+ 'VM.Config.Network',
52
+ 'VM.Config.Options',
53
+ 'VM.PowerMgmt',
54
+ ]);
55
+
56
+ /** The names a baseline uses. Every field has a generic default (`PROVISION_DEFAULTS`). */
57
+ export interface ProvisionNames {
58
+ /** The role holding `PROVISION_PRIVILEGES` on `/`. */
59
+ readonly role?: string | undefined;
60
+ /** The user OpenBao's `provision` role mints tokens for, realm-qualified. */
61
+ readonly provisionUser?: string | undefined;
62
+ /** The group whose members OpenBao may mint for — the fence around the mint. */
63
+ readonly mintGroup?: string | undefined;
64
+ /** The user OpenBao's `read` role mints for, or `null` for a baseline with no read lane. */
65
+ readonly readUser?: string | null | undefined;
66
+ /** The role the read user holds on `/`. ⛔ It must already exist: a built-in, or one you declare. */
67
+ readonly readRole?: string | undefined;
68
+ /** The comment on the group and both users — how a person on the node learns what they are. */
69
+ readonly comment?: string | undefined;
70
+ }
71
+
72
+ /** Every name, resolved: what `PROVISION_DEFAULTS` holds and what a baseline is built from. */
73
+ export type ResolvedProvisionNames = {
74
+ readonly [K in keyof ProvisionNames]-?: Exclude<ProvisionNames[K], undefined>;
75
+ };
76
+
77
+ /** ★ Generic, kit-prefixed names: nothing here is any one estate's. */
78
+ export const PROVISION_DEFAULTS: ResolvedProvisionNames = Object.freeze({
79
+ comment:
80
+ 'provision baseline from @homeflare/alchemy - OpenBao mints short-lived tokens for members',
81
+ mintGroup: 'hf-mint',
82
+ provisionUser: 'hf-provision@pve',
83
+ readRole: 'PVEAuditor',
84
+ readUser: 'hf-read@pve',
85
+ role: 'HfProvisioner',
86
+ });
87
+
88
+ /** Which credential lane a user or a grant belongs to — the OpenBao role that mints for it. */
89
+ export type ProvisionLane = 'provision' | 'read';
90
+
91
+ /** A baseline, resolved: exactly what the declaration holds and the bootstrap makes. */
92
+ export interface ProvisionBaseline {
93
+ readonly role: { readonly roleid: string; readonly privs: readonly string[] };
94
+ readonly group: { readonly groupid: string; readonly comment: string };
95
+ readonly users: readonly {
96
+ readonly lane: ProvisionLane;
97
+ readonly userid: string;
98
+ readonly comment: string;
99
+ readonly groups: readonly string[];
100
+ }[];
101
+ /** Each binds one user to one role on `/`, with propagate — the only shape the lanes need. */
102
+ readonly grants: readonly {
103
+ readonly lane: ProvisionLane;
104
+ readonly userid: string;
105
+ readonly roleid: string;
106
+ }[];
107
+ }
108
+
109
+ /**
110
+ * ⛔ NAMES ARE CHECKED HERE, ONCE, FOR BOTH HALVES — and conservatively. The bootstrap pastes them
111
+ * into a root shell, so a name that would need quoting is refused rather than quoted: PVE's own
112
+ * ids (`pve-roleid`, `pve-groupid`, `pve-userid`) are narrower than what a shell tolerates.
113
+ */
114
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
115
+ const USERID = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z][A-Za-z0-9._-]*$/;
116
+ const COMMENT = /^[A-Za-z0-9 ._,()@/+-]*$/;
117
+
118
+ const problems = (names: ResolvedProvisionNames): string[] => {
119
+ const found: string[] = [];
120
+ if (!ID.test(names.role)) found.push(`role \`${names.role}\` is not a PVE role id`);
121
+ if (!ID.test(names.mintGroup)) found.push(`mintGroup \`${names.mintGroup}\` is not a group id`);
122
+ if (!ID.test(names.readRole)) found.push(`readRole \`${names.readRole}\` is not a PVE role id`);
123
+ for (const [field, userid] of [
124
+ ['provisionUser', names.provisionUser],
125
+ ['readUser', names.readUser],
126
+ ] as const) {
127
+ if (userid !== null && !USERID.test(userid)) {
128
+ found.push(`${field} \`${userid}\` is not a realm-qualified user id (name@realm)`);
129
+ }
130
+ }
131
+ if (names.readUser === names.provisionUser) found.push('readUser and provisionUser are one user');
132
+ if (!COMMENT.test(names.comment))
133
+ found.push('comment holds a character the bootstrap will not quote');
134
+ return found;
135
+ };
136
+
137
+ /** Resolve `names` over the defaults and check them. Throws with every problem at once. */
138
+ export const provisionBaseline = (names: ProvisionNames = {}): ProvisionBaseline => {
139
+ // ⚠️ An explicit `undefined` means "the default", not "blank": spread alone would keep it.
140
+ const given = Object.entries(names).filter(([, value]) => value !== undefined);
141
+ const resolved: ResolvedProvisionNames = {
142
+ ...PROVISION_DEFAULTS,
143
+ ...Object.fromEntries(given),
144
+ };
145
+ const found = problems(resolved);
146
+ if (found.length > 0) throw new Error(`provision baseline: ${found.join('; ')}`);
147
+ const lanes: [ProvisionLane, string, string][] = [
148
+ ['provision', resolved.provisionUser, resolved.role],
149
+ ];
150
+ if (resolved.readUser !== null) lanes.push(['read', resolved.readUser, resolved.readRole]);
151
+ return {
152
+ grants: lanes.map(([lane, userid, roleid]) => ({ lane, roleid, userid })),
153
+ group: { comment: resolved.comment, groupid: resolved.mintGroup },
154
+ role: { privs: PROVISION_PRIVILEGES, roleid: resolved.role },
155
+ users: lanes.map(([lane, userid]) => ({
156
+ comment: resolved.comment,
157
+ groups: [resolved.mintGroup],
158
+ lane,
159
+ userid,
160
+ })),
161
+ };
162
+ };