@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.
- package/README.md +5 -4
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/proxmox/acl-wire.d.ts +26 -0
- package/dist/proxmox/acl-wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +28 -26
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +48 -89
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +2 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/client.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +27 -0
- package/dist/proxmox/credential-errors.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +0 -38
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/distilled-guard.d.ts +31 -0
- package/dist/proxmox/distilled-guard.d.ts.map +1 -0
- package/dist/proxmox/distilled-pve.d.ts +11 -4
- package/dist/proxmox/distilled-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts +68 -0
- package/dist/proxmox/group-wire.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +20 -24
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +730 -511
- package/dist/proxmox/index.js.map +40 -33
- package/dist/proxmox/lease-cache.d.ts +1 -1
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +50 -0
- package/dist/proxmox/mint.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +2 -2
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +4 -4
- package/dist/proxmox/role-wire.d.ts +28 -0
- package/dist/proxmox/role-wire.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +36 -52
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +50 -0
- package/dist/proxmox/unreadable-read.d.ts.map +1 -0
- package/dist/proxmox/user-wire.d.ts +47 -0
- package/dist/proxmox/user-wire.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +25 -32
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +2 -2
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- package/docs/upstream-conformance.md +14 -4
- package/package.json +2 -2
- package/src/grafana/index.ts +6 -5
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +36 -83
- package/src/proxmox/adopt-noop.test.ts +18 -0
- package/src/proxmox/api-token-adopt.test.ts +28 -12
- package/src/proxmox/api-token-form.ts +50 -93
- package/src/proxmox/api-token.ts +155 -133
- package/src/proxmox/client.ts +2 -7
- package/src/proxmox/constraints-forms.test.ts +7 -27
- package/src/proxmox/credential-errors.ts +33 -0
- package/src/proxmox/credentials.test.ts +29 -3
- package/src/proxmox/credentials.ts +4 -134
- package/src/proxmox/distilled-guard.ts +42 -0
- package/src/proxmox/distilled-pve.ts +15 -4
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -4
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/group-wire.ts +117 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +127 -109
- package/src/proxmox/lease-cache.ts +2 -1
- package/src/proxmox/lxc-lifecycle.ts +2 -1
- package/src/proxmox/mint.ts +158 -0
- package/src/proxmox/network-apply.ts +1 -1
- package/src/proxmox/provision-cli-fake.ts +11 -0
- package/src/proxmox/provision-declare.test.ts +6 -1
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +152 -104
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +148 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +136 -131
- 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
|
+
});
|
package/src/proxmox/role.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
|
25
|
-
* could only ever grow,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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` —
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
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
|
+
);
|