@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
@@ -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
+ });
@@ -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
+ });