@homeflare/alchemy 0.31.0 → 0.31.2

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 (133) hide show
  1. package/README.md +5 -4
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/proxmox/acl-wire.d.ts +26 -0
  5. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  6. package/dist/proxmox/acl.d.ts.map +1 -1
  7. package/dist/proxmox/api-token-form.d.ts +28 -26
  8. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  9. package/dist/proxmox/api-token.d.ts +48 -89
  10. package/dist/proxmox/api-token.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  12. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  13. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  14. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  15. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  16. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  17. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  18. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  19. package/dist/proxmox/client.d.ts +2 -2
  20. package/dist/proxmox/client.d.ts.map +1 -1
  21. package/dist/proxmox/credential-errors.d.ts +27 -0
  22. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  23. package/dist/proxmox/credentials.d.ts +0 -38
  24. package/dist/proxmox/credentials.d.ts.map +1 -1
  25. package/dist/proxmox/distilled-guard.d.ts +31 -0
  26. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  27. package/dist/proxmox/distilled-pve.d.ts +11 -4
  28. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  29. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  44. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  45. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  47. package/dist/proxmox/group-wire.d.ts +68 -0
  48. package/dist/proxmox/group-wire.d.ts.map +1 -0
  49. package/dist/proxmox/group.d.ts +20 -24
  50. package/dist/proxmox/group.d.ts.map +1 -1
  51. package/dist/proxmox/index.js +730 -511
  52. package/dist/proxmox/index.js.map +40 -33
  53. package/dist/proxmox/lease-cache.d.ts +1 -1
  54. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  55. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  56. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  57. package/dist/proxmox/mint.d.ts +50 -0
  58. package/dist/proxmox/mint.d.ts.map +1 -0
  59. package/dist/proxmox/network-apply-read.d.ts +2 -2
  60. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  61. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  62. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  63. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  64. package/dist/proxmox/resource.d.ts +4 -4
  65. package/dist/proxmox/role-wire.d.ts +28 -0
  66. package/dist/proxmox/role-wire.d.ts.map +1 -0
  67. package/dist/proxmox/role.d.ts +36 -52
  68. package/dist/proxmox/role.d.ts.map +1 -1
  69. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  70. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  71. package/dist/proxmox/unreadable-read.d.ts +50 -0
  72. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  73. package/dist/proxmox/user-wire.d.ts +47 -0
  74. package/dist/proxmox/user-wire.d.ts.map +1 -0
  75. package/dist/proxmox/user.d.ts +25 -32
  76. package/dist/proxmox/user.d.ts.map +1 -1
  77. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  78. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  79. package/dist/verify/fake-engine.d.ts +14 -0
  80. package/dist/verify/fake-engine.d.ts.map +1 -1
  81. package/docs/grafana.md +47 -17
  82. package/docs/upstream-conformance.md +14 -4
  83. package/package.json +2 -2
  84. package/src/grafana/index.ts +6 -5
  85. package/src/proxmox/acl-wire.ts +83 -0
  86. package/src/proxmox/acl.ts +36 -83
  87. package/src/proxmox/adopt-noop.test.ts +18 -0
  88. package/src/proxmox/api-token-adopt.test.ts +28 -12
  89. package/src/proxmox/api-token-form.ts +50 -93
  90. package/src/proxmox/api-token.ts +155 -133
  91. package/src/proxmox/client.ts +2 -7
  92. package/src/proxmox/constraints-forms.test.ts +7 -27
  93. package/src/proxmox/credential-errors.ts +33 -0
  94. package/src/proxmox/credentials.test.ts +29 -3
  95. package/src/proxmox/credentials.ts +4 -134
  96. package/src/proxmox/distilled-guard.ts +42 -0
  97. package/src/proxmox/distilled-pve.ts +15 -4
  98. package/src/proxmox/generated/constraints/index.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  100. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/group-wire.ts +117 -0
  116. package/src/proxmox/group.test.ts +110 -0
  117. package/src/proxmox/group.ts +127 -109
  118. package/src/proxmox/lease-cache.ts +2 -1
  119. package/src/proxmox/lxc-lifecycle.ts +2 -1
  120. package/src/proxmox/mint.ts +158 -0
  121. package/src/proxmox/network-apply.ts +1 -1
  122. package/src/proxmox/provision-cli-fake.ts +11 -0
  123. package/src/proxmox/provision-declare.test.ts +6 -1
  124. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  125. package/src/proxmox/role-wire.ts +49 -0
  126. package/src/proxmox/role.test.ts +89 -0
  127. package/src/proxmox/role.ts +152 -104
  128. package/src/proxmox/unreadable-read.test.ts +83 -0
  129. package/src/proxmox/unreadable-read.ts +60 -0
  130. package/src/proxmox/user-wire.ts +148 -0
  131. package/src/proxmox/user.test.ts +142 -0
  132. package/src/proxmox/user.ts +136 -131
  133. package/src/verify/fake-engine.ts +23 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The 0.31.1 regression (measured live 2026-09-24, homeflare-proxmox `bun run plan`): every
3
+ * `Proxmox.User`/`Proxmox.Group` row planned `update` with no warning, because their reads were
4
+ * silently folding EVERY failure — not only a refused credential — into "absent". Two fixes,
5
+ * both pinned here with REAL-SHAPED fixtures (live response bodies, nothing secret):
6
+ *
7
+ * 1. `access.getAccessUser`/`getAccessGroup` must be called with ONLY the schema's own label
8
+ * field, never the whole declared props object — the root cause: distilled's `buildRequest`
9
+ * treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
10
+ * a bodyless GET, which a stricter fetch client refuses outright.
11
+ * 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent" — only
12
+ * `read`/`reconcile` may, where a wrongful fold costs at most a redundant, loudly-refused
13
+ * create, never a silent wrong write.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import * as Layer from 'effect/Layer';
17
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
18
+ import { engineOver } from '../verify/fake-engine.ts';
19
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
20
+ import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
21
+ import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
22
+
23
+ /**
24
+ * A fake cluster whose GET on `path` answers 200 with `live`, but REFUSES (500, mimicking PVE's
25
+ * real "InternalServerError" shape) once `transientFrom` is `true` — and asserts no request ever
26
+ * carries a body on a GET, the request-shape regression's own signature.
27
+ */
28
+ const clusterWithTransientFailure = (path: string, live: unknown) => {
29
+ let transient = false;
30
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
31
+ const request =
32
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
33
+ const url = new URL(request.url);
34
+ if (url.pathname.includes('/creds/')) {
35
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
36
+ return Response.json({ data, lease_duration: 0 });
37
+ }
38
+ if (request.method === 'GET') {
39
+ const body = await request.text();
40
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses this outright.
41
+ expect(body).toBe('');
42
+ }
43
+ // ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}`/
44
+ // `{groupid}` label substitution (`tim@pve` -> `tim%40pve`), which a literal `path` compare
45
+ // would silently never match, turning every case below into a no-op stub.
46
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
47
+ if (wire !== path) return Response.json({ data: null });
48
+ if (transient) {
49
+ // MEASURED 2026-09-24, live TB4: a missing user/group answers exactly this shape.
50
+ return Response.json({ data: null, message: `no such object (${path})` }, { status: 500 });
51
+ }
52
+ return Response.json({ data: live });
53
+ };
54
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
55
+ return {
56
+ breakReads: () => {
57
+ transient = true;
58
+ },
59
+ layer: FetchHttpClient.layer.pipe(
60
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
61
+ ),
62
+ };
63
+ };
64
+
65
+ describe('a transient read failure fails the plan loudly, never a silent false update', () => {
66
+ test('Proxmox.User: diff() propagates a transient read failure once a state row exists', async () => {
67
+ // MEASURED 2026-09-24, live TB4 `GET /access/users/tim@pve`.
68
+ const live = {
69
+ comment: 'Primary admin — PVE fallback',
70
+ email: 'tim@schenanigans.com',
71
+ enable: 1,
72
+ expire: 0,
73
+ firstname: 'Timothy',
74
+ groups: ['Schenanigans'],
75
+ lastname: 'Schneider',
76
+ tokens: null,
77
+ };
78
+ const declare = () =>
79
+ ProxmoxUser('tim', {
80
+ comment: 'Primary admin — PVE fallback',
81
+ email: 'tim@schenanigans.com',
82
+ firstname: 'Timothy',
83
+ groups: ['Schenanigans'],
84
+ lastname: 'Schneider',
85
+ target: FAKE_TARGET,
86
+ userid: 'tim@pve',
87
+ });
88
+ const fake = clusterWithTransientFailure('access/users/tim@pve', live);
89
+ await withoutBao(async () => {
90
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
91
+ // First deploy: adopts the matching account, records a state row.
92
+ expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
93
+ // Now every read of this account fails the way the live regression measured.
94
+ fake.breakReads();
95
+ // ⛔ THE FIX: this must REJECT (fail the plan), never resolve with a false 'update'.
96
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
97
+ });
98
+ });
99
+
100
+ test('Proxmox.Group: diff() propagates a transient read failure once a state row exists', async () => {
101
+ // MEASURED 2026-09-24, live TB4 `GET /access/groups/HomeAssistant`.
102
+ const live = { comment: 'Home Assistant service group', members: [] };
103
+ const declare = () =>
104
+ ProxmoxGroup('ha', {
105
+ comment: 'Home Assistant service group',
106
+ groupid: 'HomeAssistant',
107
+ target: FAKE_TARGET,
108
+ });
109
+ const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
110
+ await withoutBao(async () => {
111
+ const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
112
+ expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
113
+ fake.breakReads();
114
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
115
+ });
116
+ });
117
+
118
+ // ★ A brand-new declaration's own create-from-a-500 path is already covered end to end by
119
+ // user.test.ts's `missingAs500` fixture — `readUser`/`readGroup` (used by `reconcile`) kept
120
+ // their fold on purpose, exactly so that path stays unaffected by this fix.
121
+ });
122
+
123
+ describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
124
+ // ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS VERY FIX, 2026-09-24 — NOT part of the live-measured
125
+ // regression above, but the SAME bug class one layer deeper. `alchemy/src/Drift.ts` (`alchemy
126
+ // drift`/`sync`/`deploy --detect-drift`) calls a provider's `read` hook DIRECTLY on an
127
+ // already-confirmed row (`output: old.attr`, defined — Drift.ts:239-292), the same situation
128
+ // `diff` handles above. Before `user.ts`/`group.ts`'s `read` hook learned to branch on
129
+ // `output` (this fix's second half), it ALWAYS called the folding `readUser`/`readGroup` —
130
+ // so a transient failure here folded to `undefined`, and Drift.ts's own dry-run branch
131
+ // reports that as `{action: 'missing'}` with NO error surfaced at all: a silent false "this
132
+ // account is gone" report, worse than the false `update` `diff` used to produce because
133
+ // nothing here even logs a warning. `engineOver`'s new `drift` capability
134
+ // (verify/fake-engine.ts) drives the real `Alchemy.Drift.detect` over the same fake cluster.
135
+ test('Proxmox.User: a confirmed row propagates instead of reporting missing', async () => {
136
+ const live = {
137
+ comment: 'Primary admin — PVE fallback',
138
+ email: 'tim@schenanigans.com',
139
+ enable: 1,
140
+ expire: 0,
141
+ firstname: 'Timothy',
142
+ groups: ['Schenanigans'],
143
+ lastname: 'Schneider',
144
+ tokens: null,
145
+ };
146
+ const declare = () =>
147
+ ProxmoxUser('tim', {
148
+ comment: 'Primary admin — PVE fallback',
149
+ email: 'tim@schenanigans.com',
150
+ firstname: 'Timothy',
151
+ groups: ['Schenanigans'],
152
+ lastname: 'Schneider',
153
+ target: FAKE_TARGET,
154
+ userid: 'tim@pve',
155
+ });
156
+ const fake = clusterWithTransientFailure('access/users/tim@pve', live);
157
+ await withoutBao(async () => {
158
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
159
+ expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
160
+ fake.breakReads();
161
+ // ⛔ THE FIX: this must REJECT. Before `read`'s `output` branch, it resolved with
162
+ // `{ tim: { action: 'missing' } }` and no error anywhere.
163
+ await expect(engine.drift(declare())).rejects.toBeDefined();
164
+ });
165
+ });
166
+
167
+ test('Proxmox.Group: a confirmed row propagates instead of reporting missing', async () => {
168
+ const live = { comment: 'Home Assistant service group', members: [] };
169
+ const declare = () =>
170
+ ProxmoxGroup('ha', {
171
+ comment: 'Home Assistant service group',
172
+ groupid: 'HomeAssistant',
173
+ target: FAKE_TARGET,
174
+ });
175
+ const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
176
+ await withoutBao(async () => {
177
+ const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
178
+ expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
179
+ fake.breakReads();
180
+ await expect(engine.drift(declare())).rejects.toBeDefined();
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `Proxmox.Role`'s wire shape: PVE's create/update forms and how a live list row becomes
3
+ * attributes. Split out of role.ts (2026-09-24, the distilled migration) to keep that file
4
+ * under the 250-line cap — the api-token.ts/api-token-form.ts seam.
5
+ */
6
+ import type * as access from '@distilled.cloud/proxmox/access';
7
+ import type { RoleAttributes, RoleProps } from './role.ts';
8
+
9
+ export const ROLE_CREATE = 'pve:POST /access/roles';
10
+ export const ROLE_UPDATE = 'pve:PUT /access/roles/{roleid}';
11
+
12
+ /**
13
+ * The only shape two privilege sets may be compared in: sorted, de-duplicated, blanks dropped.
14
+ *
15
+ * ⚠️ THE TRIM AND THE EMPTY FILTER ARE NOT DECORATION. `'A,B,'.split(',')` yields a trailing `''`,
16
+ * and a hand-written list is quite likely to carry a stray space after a comma. Either one turns
17
+ * into a phantom member that no live answer can contain, so `matches` would be false on every
18
+ * plan and the deploy would rewrite the role to exactly what it already was.
19
+ */
20
+ export const canonical = (privs: readonly string[]): string[] =>
21
+ [...new Set(privs.map((priv) => priv.trim()).filter((priv) => priv.length > 0))].sort();
22
+
23
+ export const createForm = (props: RoleProps): access.CreateAccessRoleRequest => ({
24
+ privs: canonical(props.privs).join(','),
25
+ roleid: props.roleid,
26
+ });
27
+
28
+ /**
29
+ * ⚠️ NO `append` FIELD, DELIBERATELY. Sending `append=1` would make every update additive, so a
30
+ * privilege could be granted from here but never taken away — see role.ts's own header.
31
+ */
32
+ export const updateForm = (props: RoleProps): access.PutAccessRoleRequest => ({
33
+ privs: canonical(props.privs).join(','),
34
+ roleid: props.roleid,
35
+ });
36
+
37
+ export const find = (rows: readonly access.ListAccessRolesResponseBodyItem[], roleid: string) =>
38
+ rows.find((row) => row.roleid === roleid);
39
+
40
+ export const attributesOf = (
41
+ row: access.ListAccessRolesResponseBodyItem,
42
+ props: RoleProps,
43
+ ): RoleAttributes => ({
44
+ privs: canonical(row.privs === undefined ? [] : row.privs.split(',')),
45
+ roleid: props.roleid,
46
+ });
47
+
48
+ export const matches = (attributes: RoleAttributes, props: RoleProps) =>
49
+ canonical(attributes.privs).join(',') === canonical(props.privs).join(',');
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `Proxmox.Role` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
3
+ * `@distilled.cloud/proxmox` protocol — create, drift, and delete, reading via the LIST (not the
4
+ * item) as role.ts's header explains. adopt-noop.test.ts is the cross-family "adopting a
5
+ * matching object only reads" proof; this file is Role's own write paths.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+ import { engineOver } from '../verify/fake-engine.ts';
11
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
12
+ import { ProxmoxRole, ProxmoxRoleProvider } from './role.ts';
13
+
14
+ type Row = { roleid: string; privs: string | undefined };
15
+
16
+ /** `GET /access/roles` (list) and `POST`/`PUT`/`DELETE .../roles(/{roleid})`, one flat list. */
17
+ const cluster = (rows: Row[]) =>
18
+ fakePve((call: PveCall) => {
19
+ if (call.method === 'GET' && call.path === 'access/roles') return rows;
20
+ const match = /^access\/roles(?:\/(.+))?$/.exec(call.path);
21
+ if (match === null) return undefined;
22
+ const roleid = match[1];
23
+ if (call.method === 'POST' && roleid === undefined) {
24
+ rows.push({ privs: call.form['privs'], roleid: call.form['roleid'] ?? '' });
25
+ return undefined;
26
+ }
27
+ if (call.method === 'PUT' && roleid !== undefined) {
28
+ const idx = rows.findIndex((r) => r.roleid === roleid);
29
+ if (idx !== -1) rows[idx] = { privs: call.form['privs'], roleid };
30
+ return undefined;
31
+ }
32
+ if (call.method === 'DELETE' && roleid !== undefined) {
33
+ const idx = rows.findIndex((r) => r.roleid === roleid);
34
+ if (idx !== -1) rows.splice(idx, 1);
35
+ return undefined;
36
+ }
37
+ return undefined;
38
+ });
39
+
40
+ const declare = (privs: string[]) =>
41
+ ProxmoxRole('mint', { privs, roleid: 'MintTokens', target: FAKE_TARGET });
42
+
43
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
44
+ engineOver(ProxmoxRoleProvider().pipe(Layer.provideMerge(fake.layer)));
45
+
46
+ describe('Proxmox.Role over the distilled protocol', () => {
47
+ test('creating a role that does not exist yet: one POST, privileges sorted and joined', async () => {
48
+ const rows: Row[] = [];
49
+ const fake = cluster(rows);
50
+ await withoutBao(async () => {
51
+ const engine = engineFor(fake);
52
+ expect(await engine.deploy(declare(['User.Modify', 'Sys.Audit']))).toEqual({
53
+ mint: 'create',
54
+ });
55
+ });
56
+ expect(fake.writes()).toEqual(['POST access/roles']);
57
+ expect(rows).toEqual([{ privs: 'Sys.Audit,User.Modify', roleid: 'MintTokens' }]);
58
+ });
59
+
60
+ test('a drifted privilege set is corrected with exactly one PUT, no append field', async () => {
61
+ const rows: Row[] = [{ privs: 'Sys.Audit', roleid: 'MintTokens' }];
62
+ const fake = cluster(rows);
63
+ await withoutBao(async () => {
64
+ const engine = engineFor(fake);
65
+ const declared = () => declare(['Sys.Audit', 'User.Modify']);
66
+ const report = await engine.verify(declared());
67
+ expect(report.rows[0]).toMatchObject({ diff: 'update' });
68
+ expect(await engine.deploy(declared())).toEqual({ mint: 'adopted' });
69
+ });
70
+ const put = fake.calls.find((call) => call.method === 'PUT');
71
+ expect(put?.form['privs']).toBe('Sys.Audit,User.Modify');
72
+ expect(put?.pairs.some(([name]) => name === 'append')).toBe(false);
73
+ expect(rows).toEqual([{ privs: 'Sys.Audit,User.Modify', roleid: 'MintTokens' }]);
74
+ });
75
+
76
+ // ⚠️ A role does not protect itself the way `destroy` in resource.ts describes for other
77
+ // objects (role.ts's own ⛔) — deleting one always sends the DELETE.
78
+ test('deleting a role sends the DELETE', async () => {
79
+ const rows: Row[] = [{ privs: 'Sys.Audit', roleid: 'MintTokens' }];
80
+ const fake = cluster(rows);
81
+ await withoutBao(async () => {
82
+ const engine = engineFor(fake);
83
+ await engine.deploy(declare(['Sys.Audit']));
84
+ expect(await engine.deploy(Effect.void)).toEqual({});
85
+ });
86
+ expect(fake.writes()).toEqual(['DELETE access/roles/MintTokens']);
87
+ expect(rows).toEqual([]);
88
+ });
89
+ });
@@ -2,54 +2,74 @@
2
2
  * `Proxmox.Role` — a PVE role: one name, one set of privileges. The object every other resource
3
3
  * in this package stands on, because a role is what the provision credential actually holds.
4
4
  *
5
- * ★ THIS IS WHERE pool.ts's ⚠️ STOPS BEING A MEMORY. That file records, in prose, that the
6
- * provision role lacked `Pool.Allocate`, that reconcile answered "Permission check failed", and
7
- * that a human widened the role over SSH with `pveum role modify`. Prose cannot be planned
8
- * against: it goes stale the first time somebody edits the role in the UI and tells nobody.
9
- * Declared here, the same fact is a diff — the privileges the role is SUPPOSED to hold are read
10
- * off the cluster on every plan, and a hand edit shows up as `1 to update` rather than as a 403
11
- * six weeks later in the middle of something else.
12
- *
13
- * ⛔ THE INDEX AND THE ITEM DISAGREE ABOUT WHAT `privs` IS, AND MISSING THAT COSTS A DIFF THAT
14
- * NEVER CONVERGES. `GET /access/roles` (the index) reports each role's privileges as a COMMA
15
- * STRING. `GET /access/roles/{roleid}` — the path this resource reads — returns a privilege MAP
16
- * instead, `{"VM.Allocate":1,"Sys.Audit":1,…}`, and its key order is a Perl hash's order, so it
17
- * is not stable between two calls to the same endpoint. Compare the raw string, or the raw
18
- * object, or the keys in the order they arrived, and every plan reports an update forever.
19
- * `canonical` below is the whole answer: both sides become a sorted, de-duplicated set before
20
- * anything is compared, and `attributes` STORES the sorted form so Alchemy's state does not
21
- * churn either.
5
+ * ★ THIS IS WHERE pool.ts's ⚠️ STOPS BEING A MEMORY. Declared here, a hand edit shows up as
6
+ * `1 to update` rather than as a 403 six weeks later in the middle of something else.
22
7
  *
23
8
  * ⚠️ A DECLARATION REPLACES THE PRIVILEGE SET; IT DOES NOT ADD TO IT. `PUT /access/roles/{roleid}`
24
- * accepts an `append` flag and this resource deliberately never sends it — with append a role
25
- * could only ever grow, so `matches` would report an update forever whenever props were a subset
26
- * of live, which is the same perpetual diff by a different road. The consequence is the one that
27
- * bites: anything a human added by hand is REVOKED on the next deploy unless it is in `privs`.
28
- * Copy the live set out of `pveum role list` before declaring a role that already exists; do not
29
- * type it from memory.
9
+ * accepts an `append` flag and this resource deliberately never sends it (role-wire.ts) — with
10
+ * append a role could only ever grow, the same perpetual diff by a different road. The
11
+ * consequence: anything a human added by hand is REVOKED on the next deploy unless it is in
12
+ * `privs`. Copy the live set out of `pveum role list` before declaring a role that already
13
+ * exists; do not type it from memory.
30
14
  *
31
15
  * ⛔ A ROLE CAN LOCK ITS OWN PROVIDER OUT, AND NOTHING IN THIS PACKAGE CAN UNDO IT. Reconcile runs
32
16
  * as the provision credential, and that credential holds a role. Declare THAT role without the
33
17
  * privileges the provider needs — `Sys.Modify` on `/access` above all — and the write succeeds,
34
18
  * after which every later plan reads 403 and the repair has to happen out of band as `root@pam`
35
- * over SSH. It is a one-way door, which is why the privileges a reconcile needs are written
36
- * down at the bottom of this comment rather than left to be rediscovered from an error message.
19
+ * over SSH.
37
20
  *
38
21
  * ⚠️ PVE REFUSES TO EDIT ITS OWN BUILT-IN ROLES (`Administrator`, `NoAccess`, the `PVE*` set), and
39
- * the item endpoint gives no sign of which those are — only the index carries the `special`
40
- * flag. A declaration aimed at a built-in role therefore READS BACK CLEANLY and fails at write
41
- * time, which reads like a broken provider rather than like a refusal. Declare roles you own.
22
+ * the LIST read (below) DOES carry the `special` flag that marks them, unlike the item endpoint
23
+ * this file used before the distilled migration — `role-wire.ts`'s `attributesOf` does not use
24
+ * it, but a caller reading `list`'s own answer could. A declaration aimed at a built-in role
25
+ * still reads back cleanly and fails at write time. Declare roles you own.
42
26
  *
43
27
  * ★ PRIVILEGES A RECONCILE NEEDS: `Sys.Audit` on `/access` to read and diff, `Sys.Modify` on
44
- * `/access` to create, update or delete. Not `Permissions.Modify` — binding a role to a user is
45
- * `/access/acl`, a different object, and nothing here writes it. An auditor-shaped credential
46
- * can already PLAN this resource and cannot DEPLOY it, which is the honest failure: the plan is
47
- * true and the deploy says exactly which privilege is missing.
28
+ * `/access` to create, update or delete. Not `Permissions.Modify` — that is a different object.
29
+ *
30
+ * ★ READ VIA THE LIST, NOT THE ITEM — A DELIBERATE CHANGE FROM THE PRE-MIGRATION CODE, AND THE
31
+ * REASON IS THE ITEM'S OWN GENERATED SCHEMA. `access.getAccessRole`'s response
32
+ * (`GetAccessRoleResponse`) enumerates a FIXED list of ~47 known privilege field names
33
+ * (`Sys_Modify`, `VM_Allocate`, …), each its own optional property — MEASURED in
34
+ * `@distilled.cloud/proxmox`'s generated `access.ts`. A privilege this package's generator did
35
+ * not know about would silently not appear as a field at all, and this file's old `privs: []`
36
+ * reconstruction would drop it from state without a diff ever seeing the loss. The LIST read
37
+ * (`access.listAccessRoles`) answers `privs` as the SAME plain comma string PVE's item read
38
+ * also disagreed with the index about pre-migration — a shape with no fixed enumeration to fall
39
+ * behind. `find` below (role-wire.ts) does the item-read's old job client-side.
40
+ * ⚠️ REASONED, NOT MEASURED, THAT THE LIST READ NEEDS NO MORE THAN `read`'s AUDITOR SHAPE. The
41
+ * item read's own privilege check (`Sys.Audit` on `/access`) was measured; the list's was not
42
+ * separately checked, but PVE's own convention is that an index read is never MORE restrictive
43
+ * than its item. `readRole` stays at the default `read` on that basis, same as before.
44
+ *
45
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` ONTO `@distilled.cloud/proxmox`'s
46
+ * typed `access.listAccessRoles`/`createAccessRole`/`putAccessRole`/`deleteAccessRole`
47
+ * (2026-09-24, decision 43's proxmox walk-down). `distilled-pve.ts`'s `runPve` replaces
48
+ * `pve()`; the cries-wolf fix is wired the same way as acl.ts, group.ts and user.ts.
48
49
  */
49
50
  import { Resource } from 'alchemy';
51
+ import { isResolved } from 'alchemy/Diff';
50
52
  import * as Provider from 'alchemy/Provider';
53
+ import * as access from '@distilled.cloud/proxmox/access';
51
54
  import * as Effect from 'effect/Effect';
52
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
55
+ import { guardWrite } from './distilled-guard.ts';
56
+ import {
57
+ ROLE_CREATE,
58
+ ROLE_UPDATE,
59
+ attributesOf,
60
+ createForm,
61
+ find,
62
+ matches,
63
+ updateForm,
64
+ } from './role-wire.ts';
65
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
66
+ import { runPve } from './distilled-pve.ts';
67
+ import {
68
+ UNREADABLE,
69
+ type Unreadable,
70
+ readOrUnreadable,
71
+ unreadableWarning,
72
+ } from './unreadable-read.ts';
53
73
 
54
74
  export interface RoleProps extends WithTarget {
55
75
  /**
@@ -62,13 +82,11 @@ export interface RoleProps extends WithTarget {
62
82
  roleid: string;
63
83
  /**
64
84
  * The COMPLETE privilege set, e.g. `['VM.Allocate', 'VM.Audit', 'Sys.Audit']`. Order and
65
- * duplicates do not matter — see the ⛔ in the header — but completeness does: this list is the
85
+ * duplicates do not matter — see the header — but completeness does: this list is the
66
86
  * role, not an addition to it.
67
87
  *
68
88
  * ⚠️ DECLARE AT LEAST ONE. A role with no privileges grants nothing and reads back as an empty
69
- * document, which is the one shape the factory cannot tell apart from an object that is not
70
- * there — reconcile would then refuse with its "the write returned no error but the object is
71
- * still absent" message, which is true of the read and misleading about the cause.
89
+ * document, which reconcile cannot tell apart from an object that is not there.
72
90
  */
73
91
  privs: string[];
74
92
  }
@@ -90,74 +108,104 @@ export interface ProxmoxRole extends Resource<
90
108
  export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
91
109
 
92
110
  /**
93
- * The only shape two privilege sets may be compared in: sorted, de-duplicated, blanks dropped.
94
- *
95
- * ⚠️ THE TRIM AND THE EMPTY FILTER ARE NOT DECORATION. `'A,B,'.split(',')` yields a trailing `''`,
96
- * and a hand-written list is quite likely to carry a stray space after a comma. Either one turns
97
- * into a phantom member that no live answer can contain, so `matches` would be false on every
98
- * plan and the deploy would rewrite the role to exactly what it already was.
99
- */
100
- const canonical = (privs: readonly string[]): string[] =>
101
- [...new Set(privs.map((priv) => priv.trim()).filter((priv) => priv.length > 0))].sort();
102
-
103
- /**
104
- * The privileges in a live answer, from EITHER shape PVE uses for them.
105
- *
106
- * ⚠️ THE MAP BRANCH IS THE ONE THAT RUNS HERE — the item endpoint answers `{"VM.Allocate":1,…}`,
107
- * so the privilege names are the KEYS and the `1`s carry no information. The string branch
108
- * exists because the index answers `{"privs":"VM.Allocate,Sys.Audit",…}` for that same role, and
109
- * a reader that assumed the map shape for an index entry would compare the words `privs`,
110
- * `roleid` and `special` against real privileges and report drift forever.
111
- *
112
- * ⚠️ A KEY WITH NO DOT IS NOT A PRIVILEGE. Every PVE privilege is `Category.Name` — VM.Allocate,
113
- * Sys.Modify, Datastore.AllocateSpace, Pool.Audit — while the metadata PVE mixes into role
114
- * answers (`special`, marking a built-in) is a bare word. Filtering on the dot keeps a flag from
115
- * being diffed as though somebody had granted it.
111
+ * ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement.
112
+ * ⛔ NO `orElseSucceed` — found 2026-09-24, the same bug class as the credential denial fix. A
113
+ * role's absence is ALWAYS a SUCCESSFUL read (the list comes back and the role is not in it —
114
+ * `access.listAccessRoles` never 404s/500s per-role the way `getAccessUser`/`getAccessGroup`
115
+ * do, MEASURED), so this family never needed a folding/non-folding split the way user.ts and
116
+ * group.ts do: any THROWN failure here is genuinely unexpected and must propagate and fail the
117
+ * whole plan loudly, in `diff`, `read` and `reconcile` alike — never fold to "absent → update".
116
118
  */
117
- const livePrivs = (live: Record<string, unknown>): string[] => {
118
- const listed = live['privs'];
119
- if (typeof listed === 'string') return canonical(listed.split(','));
120
- return canonical(Object.keys(live).filter((key) => key.includes('.')));
121
- };
119
+ const readRole = (props: RoleProps) =>
120
+ readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
121
+ Effect.map((rows) => {
122
+ if (rows === UNREADABLE) return UNREADABLE;
123
+ const row = find(rows, props.roleid);
124
+ return row === undefined ? undefined : attributesOf(row, props);
125
+ }),
126
+ );
122
127
 
123
- const handlers = pveHandlers<RoleProps, RoleAttributes>({
124
- attributes: (live, props) => ({ privs: livePrivs(live), roleid: props.roleid }),
125
- collection: () => 'access/roles',
126
- /** ⚠️ PVE wants ONE comma string here, not a repeated field — `privs=A,B,C`. */
127
- createForm: (props) => ({ privs: canonical(props.privs).join(','), roleid: props.roleid }),
128
- /**
129
- * ⛔ NORMALISED ON BOTH SIDES, even though `attributes` already arrives sorted. This is a
130
- * predicate, not a fast path: the cost of trusting one side's order is not a slow plan, it is
131
- * a plan that reports an update every single time and a deploy that writes the same role back
132
- * forever. Cheap insurance against the exact trap named in the header.
133
- */
134
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
135
- endpoint: { create: 'pve:POST /access/roles', update: 'pve:PUT /access/roles/{roleid}' },
136
- matches: (attributes, props) =>
137
- canonical(attributes.privs).join(',') === canonical(props.privs).join(','),
138
- path: (props) => `access/roles/${props.roleid}`,
139
- /**
140
- * ⚠️ NO `append` FIELD, DELIBERATELY. Sending `append=1` would make every update additive, so a
141
- * privilege could be granted from here but never taken away — and a role that cannot narrow
142
- * is not a declaration. See the ⚠️ in the header for what that means for hand edits.
143
- */
144
- updateForm: (props) => ({ privs: canonical(props.privs).join(',') }),
145
- });
128
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
129
+ const dropUnreadable = (live: RoleAttributes | Unreadable | undefined) =>
130
+ live === UNREADABLE ? undefined : live;
146
131
 
147
- /**
148
- * ⛔ AN EMPTY LIST, AND NOWHERE DOES IT MATTER MORE. `GET /access/roles` returns every
149
- * role on the cluster, PVE's own built-ins included. Returning them would invite Alchemy
150
- * to adopt `Administrator` — and therefore one day to narrow or delete it. Adoption is
151
- * an explicit act, here as everywhere else in this package.
152
-
153
- *
154
- * ⛔ DO NOT CARRY THE FACTORY'S REASSURANCE OVER TO THIS ONE. `destroy` there notes that
155
- * PVE refuses to delete things still in use — a pool holding guests, a storage with
156
- * volumes — and a role is NOT protected that way. Whoever is bound to the role loses
157
- * those privileges the moment it goes, and the credential this provider runs with is
158
- * bound through exactly such a binding. Checking who holds a role before removing it is
159
- * an operator's job, and it is not done here on their behalf while they read a diff.
160
-
161
- */
162
132
  export const ProxmoxRoleProvider = () =>
163
- Provider.effect(ProxmoxRole, Effect.succeed(ProxmoxRole.Provider.of(handlers)));
133
+ Provider.effect(
134
+ ProxmoxRole,
135
+ Effect.succeed(
136
+ ProxmoxRole.Provider.of({
137
+ /**
138
+ * ⛔ AN EMPTY LIST, AND NOWHERE DOES IT MATTER MORE. `GET /access/roles` returns every
139
+ * role on the cluster, PVE's own built-ins included. Adoption is an explicit act.
140
+ */
141
+ list: () => Effect.succeed([]),
142
+ read: Effect.fn(function* ({ olds }) {
143
+ return dropUnreadable(yield* readRole(olds));
144
+ }),
145
+ diff: Effect.fn(function* ({ news, output }) {
146
+ if (!isResolved(news)) return undefined;
147
+ yield* guardWrite(ROLE_CREATE, createForm(news), output === undefined);
148
+ yield* guardWrite(ROLE_UPDATE, updateForm(news), false);
149
+ if (output === undefined) return undefined;
150
+ const live = yield* readRole(news);
151
+ // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
152
+ // `update` on a role that was plainly there — see unreadable-read.ts.
153
+ if (live === UNREADABLE) {
154
+ yield* unreadableWarning('Proxmox.Role', news.roleid);
155
+ return { action: 'noop' } as const;
156
+ }
157
+ if (live === undefined) {
158
+ yield* guardWrite(ROLE_CREATE, createForm(news), true);
159
+ return { action: 'update' } as const;
160
+ }
161
+ return matches(live, news)
162
+ ? ({ action: 'noop' } as const)
163
+ : ({ action: 'update' } as const);
164
+ }),
165
+ reconcile: Effect.fn(function* ({ news }) {
166
+ // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
167
+ // write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
168
+ // handles — and a wrongful create in that race fails loudly ("already exists")
169
+ // rather than silently duplicating, group.ts's reconcile has the same note.
170
+ const before = dropUnreadable(yield* readRole(news));
171
+ yield* guardWrite(ROLE_CREATE, createForm(news), before === undefined);
172
+ yield* guardWrite(ROLE_UPDATE, updateForm(news), false);
173
+ if (before === undefined) {
174
+ yield* runPve(
175
+ news.target,
176
+ 'provision',
177
+ true,
178
+ access.createAccessRole(createForm(news)),
179
+ );
180
+ } else if (!matches(before, news)) {
181
+ yield* runPve(news.target, 'provision', true, access.putAccessRole(updateForm(news)));
182
+ }
183
+ const after = dropUnreadable(yield* readRole(news));
184
+ if (after === undefined) {
185
+ return yield* Effect.die(
186
+ new Error(
187
+ `access/roles/${news.roleid}: the write returned no error but the role is ` +
188
+ 'still absent. PVE wraps every answer in {"data":...} and can report success ' +
189
+ 'on a call that did nothing -- read back rather than trusting the status code.',
190
+ ),
191
+ );
192
+ }
193
+ return after;
194
+ }),
195
+ /**
196
+ * ⛔ DO NOT CARRY resource.ts's DESTROY REASSURANCE OVER TO THIS ONE. PVE does not refuse
197
+ * to delete a role still in use — whoever is bound to it loses those privileges the
198
+ * moment it goes, and the credential THIS PROVIDER runs with is bound through exactly
199
+ * such a binding. Checking who holds a role before removing it is an operator's job.
200
+ */
201
+ delete: Effect.fn(function* ({ olds }) {
202
+ yield* runPve(
203
+ olds.target,
204
+ 'provision',
205
+ true,
206
+ access.deleteAccessRole({ roleid: olds.roleid }),
207
+ );
208
+ }),
209
+ }),
210
+ ),
211
+ );