@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,94 @@
1
+ /**
2
+ * A target and the matcher that routes to it, declared together the way docs/pbs-notifications.md
3
+ * shows — with the matcher naming the target through its OUTPUT, not a string.
4
+ *
5
+ * ★ WHY THE OUTPUT: PBS refuses to create a matcher naming a target that does not exist, and
6
+ * refuses to delete a target a matcher still names. `targets: [hook.name]` gives Alchemy the
7
+ * dependency edge that orders both; a bare `'alertmanager'` string gives it nothing, and the
8
+ * order is then whatever the engine picks. This pins that the Output resolves inside the list
9
+ * (the matcher's `isResolved` guard) and that the target is written first.
10
+ */
11
+ import { expect, test } from 'bun:test';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Layer from 'effect/Layer';
14
+ import { engineOver } from '../verify/fake-engine.ts';
15
+ import { alertmanagerAlertBody } from './alertmanager-body.ts';
16
+ import type { PbsTarget } from './credentials.ts';
17
+ import { fakeNotify } from './fake-pbs-notify.ts';
18
+ import { withoutBao } from './fake-pve.ts';
19
+ import {
20
+ PbsNotificationMatcher,
21
+ PbsNotificationMatcherProvider,
22
+ } from './pbs-notification-matcher.ts';
23
+ import { PbsNotificationTarget, PbsNotificationTargetProvider } from './pbs-notification-target.ts';
24
+
25
+ const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
26
+
27
+ const stack = Effect.gen(function* () {
28
+ const hook = yield* PbsNotificationTarget('alertmanager', {
29
+ body: alertmanagerAlertBody(),
30
+ header: { 'Content-Type': 'application/json' },
31
+ method: 'post',
32
+ name: 'alertmanager',
33
+ target: PBS,
34
+ type: 'webhook',
35
+ url: 'https://alertmanager.example.com/api/v2/alerts',
36
+ });
37
+ yield* PbsNotificationMatcher('page-on-failure', {
38
+ 'match-field': ['exact:type=gc,prune,sync,verify'],
39
+ 'match-severity': ['error'],
40
+ name: 'page-on-failure',
41
+ target: PBS,
42
+ targets: [hook.name],
43
+ });
44
+ });
45
+
46
+ test('the matcher resolves the target by Output and is written after it', async () => {
47
+ const fake = fakeNotify();
48
+ const providers = Layer.mergeAll(
49
+ PbsNotificationTargetProvider(),
50
+ PbsNotificationMatcherProvider(),
51
+ );
52
+ await withoutBao(async () => {
53
+ const engine = engineOver(providers.pipe(Layer.provideMerge(fake.layer)));
54
+ await engine.deploy(stack);
55
+ const again = await engine.verify(stack, { all: true });
56
+ expect(again.rows.map((row) => row.diff)).toEqual(['noop', 'noop']);
57
+ });
58
+ expect(fake.writes()).toEqual([
59
+ 'POST config/notifications/endpoints/webhook',
60
+ 'POST config/notifications/matchers',
61
+ ]);
62
+ expect(fake.objects.get('config/notifications/matchers/page-on-failure')?.['target']).toEqual([
63
+ 'alertmanager',
64
+ ]);
65
+ });
66
+
67
+ test('a new type under the same name replaces delete-first — names are unique across families', async () => {
68
+ const fake = fakeNotify();
69
+ const declare = (type: 'sendmail' | 'webhook') =>
70
+ type === 'webhook'
71
+ ? PbsNotificationTarget('pager', {
72
+ method: 'post',
73
+ name: 'pager',
74
+ target: PBS,
75
+ type,
76
+ url: 'https://alertmanager.example.com/api/v2/alerts',
77
+ })
78
+ : PbsNotificationTarget('pager', {
79
+ mailto: ['ops@example.com'],
80
+ name: 'pager',
81
+ target: PBS,
82
+ type,
83
+ });
84
+ await withoutBao(async () => {
85
+ const engine = engineOver(PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)));
86
+ await engine.deploy(declare('webhook'));
87
+ await engine.deploy(declare('sendmail'));
88
+ });
89
+ expect(fake.writes()).toEqual([
90
+ 'POST config/notifications/endpoints/webhook',
91
+ 'DELETE config/notifications/endpoints/webhook/pager',
92
+ 'POST config/notifications/endpoints/sendmail',
93
+ ]);
94
+ });
@@ -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
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The generated bootstrap, RUN: under `sh`, with real Perl, against provision-cli-fake.ts's
3
+ * `pvesh`/`pveum`. What these pin: a fresh cluster gets exactly the baseline in an order PVE accepts;
4
+ * a second run (or a node joining a cluster that has it) writes nothing; drift converges with only
5
+ * the writes it needs; a read role that is missing stops the script before any write; and the
6
+ * script never makes a token or sets a password.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { type CliState, freshCluster, runScript } from './provision-cli-fake.ts';
10
+ import { PROVISION_DEFAULTS, PROVISION_PRIVILEGES } from './provision-baseline.ts';
11
+ import { provisionBootstrap } from './provision-bootstrap.ts';
12
+
13
+ const COMMENT = PROVISION_DEFAULTS.comment;
14
+ const lane = (groups: string[]) => ({
15
+ comment: COMMENT,
16
+ email: '',
17
+ enable: 1,
18
+ expire: 0,
19
+ firstname: '',
20
+ groups,
21
+ lastname: '',
22
+ });
23
+
24
+ /** What a fresh cluster holds after the default script: the baseline, and nothing else. */
25
+ const BOOTSTRAPPED: CliState = {
26
+ acl: [
27
+ { path: '/', propagate: 1, roleid: 'HfProvisioner', type: 'user', ugid: 'hf-provision@pve' },
28
+ { path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'hf-read@pve' },
29
+ ],
30
+ groups: { 'hf-mint': { comment: COMMENT } },
31
+ roles: { ...freshCluster().roles, HfProvisioner: [...PROVISION_PRIVILEGES] },
32
+ users: { 'hf-provision@pve': lane(['hf-mint']), 'hf-read@pve': lane(['hf-mint']) },
33
+ };
34
+
35
+ describe('the script as text', () => {
36
+ const script = provisionBootstrap();
37
+
38
+ test('never makes a token or sets a password: its only writes are add/modify of the four', () => {
39
+ const calls = new Set(script.match(/pveum \w+ \w+/g));
40
+ expect([...calls].sort()).toEqual([
41
+ 'pveum acl modify',
42
+ 'pveum group add',
43
+ 'pveum group modify',
44
+ 'pveum role add',
45
+ 'pveum role modify',
46
+ 'pveum user add',
47
+ 'pveum user modify',
48
+ ]);
49
+ expect(script).not.toMatch(/--password|passwd|user token/);
50
+ expect(script).toStartWith('#!/bin/sh\n');
51
+ expect(script).toContain('\nset -eu\n');
52
+ });
53
+
54
+ test('carries the one privilege list, sorted, as the role it adds', () => {
55
+ expect(script).toContain(`want='${PROVISION_PRIVILEGES.join(',')}'`);
56
+ expect(script).toContain('pveum role add HfProvisioner --privs "$want"');
57
+ });
58
+ });
59
+
60
+ describe('run against a cluster', () => {
61
+ test('a fresh cluster gets exactly the baseline, role and group before what needs them', async () => {
62
+ const run = await runScript(provisionBootstrap(), freshCluster());
63
+ expect(run.stderr).toBe('');
64
+ expect(run.code).toBe(0);
65
+ expect(run.writes.map((w) => w.split(' --')[0])).toEqual([
66
+ 'pveum role add HfProvisioner',
67
+ 'pveum group add hf-mint',
68
+ 'pveum user add hf-provision@pve',
69
+ 'pveum user add hf-read@pve',
70
+ 'pveum acl modify /',
71
+ 'pveum acl modify /',
72
+ ]);
73
+ expect(run.state).toEqual(BOOTSTRAPPED);
74
+ });
75
+
76
+ test('a second run, or a node joining a cluster that has it, writes nothing', async () => {
77
+ const run = await runScript(provisionBootstrap(), structuredClone(BOOTSTRAPPED));
78
+ expect(run.code).toBe(0);
79
+ expect(run.writes).toEqual([]);
80
+ expect(run.stdout.split('\n').filter((line) => line.endsWith(': ok'))).toHaveLength(6);
81
+ });
82
+
83
+ test('drift converges with only the writes it needs', async () => {
84
+ const drifted = structuredClone(BOOTSTRAPPED);
85
+ drifted.roles['HfProvisioner'] = [...PROVISION_PRIVILEGES.slice(1), 'VM.Console'];
86
+ drifted.groups['admins'] = { comment: '' };
87
+ drifted.users['hf-read@pve'] = { ...lane(['admins', 'hf-mint']), email: 'x@example.com' };
88
+ drifted.acl = drifted.acl.slice(0, 1);
89
+ const run = await runScript(provisionBootstrap(), drifted);
90
+ expect(run.code).toBe(0);
91
+ expect(run.writes.map((w) => w.split(' --')[0])).toEqual([
92
+ 'pveum role modify HfProvisioner',
93
+ 'pveum user modify hf-read@pve',
94
+ 'pveum acl modify /',
95
+ ]);
96
+ expect(run.state).toEqual({ ...BOOTSTRAPPED, groups: drifted.groups });
97
+ });
98
+
99
+ test('a read role that does not exist stops the script before any write', async () => {
100
+ const run = await runScript(provisionBootstrap({ readRole: 'Auditor' }), freshCluster());
101
+ expect(run.code).toBe(1);
102
+ expect(run.stderr).toContain('role Auditor does not exist');
103
+ expect(run.writes).toEqual([]);
104
+ });
105
+
106
+ test('no read lane: one user, one grant, no read role needed', async () => {
107
+ const run = await runScript(provisionBootstrap({ readUser: null }), {
108
+ ...freshCluster(),
109
+ roles: {},
110
+ });
111
+ expect(run.code).toBe(0);
112
+ expect(Object.keys(run.state.users)).toEqual(['hf-provision@pve']);
113
+ expect(run.state.acl.map((g) => g.ugid)).toEqual(['hf-provision@pve']);
114
+ });
115
+
116
+ test("a site's own names flow through every step", async () => {
117
+ const names = {
118
+ mintGroup: 'mint',
119
+ provisionUser: 'provisioner@pve',
120
+ readUser: 'auditor@pve',
121
+ role: 'Provisioner',
122
+ };
123
+ const run = await runScript(provisionBootstrap(names), freshCluster());
124
+ expect(run.code).toBe(0);
125
+ expect(Object.keys(run.state.roles).sort()).toEqual(['PVEAuditor', 'Provisioner']);
126
+ expect(run.state.users['provisioner@pve']?.groups).toEqual(['mint']);
127
+ expect(run.state.acl.map((g) => `${g.ugid} ${g.roleid}`)).toEqual([
128
+ 'provisioner@pve Provisioner',
129
+ 'auditor@pve PVEAuditor',
130
+ ]);
131
+ });
132
+ });