@homeflare/alchemy 0.30.0 → 0.31.1
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 +22 -0
- package/dist/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- 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 +29 -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 +725 -509
- 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 +18 -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/docs/credentials.md +9 -0
- package/docs/discord.md +129 -0
- package/docs/upstream-conformance.md +17 -1
- package/package.json +5 -1
- package/src/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -0
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +29 -82
- 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 +149 -135
- 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 +63 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +135 -105
- 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/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +147 -106
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +112 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +132 -129
|
@@ -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
|
}
|
|
@@ -89,75 +107,98 @@ export interface ProxmoxRole extends Resource<
|
|
|
89
107
|
|
|
90
108
|
export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
|
|
91
109
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
116
|
-
*/
|
|
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
|
-
};
|
|
110
|
+
/** ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement. */
|
|
111
|
+
const readRole = (props: RoleProps) =>
|
|
112
|
+
readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
|
|
113
|
+
Effect.map((rows) => {
|
|
114
|
+
if (rows === UNREADABLE) return UNREADABLE;
|
|
115
|
+
const row = find(rows, props.roleid);
|
|
116
|
+
return row === undefined ? undefined : attributesOf(row, props);
|
|
117
|
+
}),
|
|
118
|
+
Effect.orElseSucceed(() => undefined),
|
|
119
|
+
);
|
|
122
120
|
|
|
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
|
-
});
|
|
121
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
122
|
+
const dropUnreadable = (live: RoleAttributes | Unreadable | undefined) =>
|
|
123
|
+
live === UNREADABLE ? undefined : live;
|
|
146
124
|
|
|
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
125
|
export const ProxmoxRoleProvider = () =>
|
|
163
|
-
Provider.effect(
|
|
126
|
+
Provider.effect(
|
|
127
|
+
ProxmoxRole,
|
|
128
|
+
Effect.succeed(
|
|
129
|
+
ProxmoxRole.Provider.of({
|
|
130
|
+
/**
|
|
131
|
+
* ⛔ AN EMPTY LIST, AND NOWHERE DOES IT MATTER MORE. `GET /access/roles` returns every
|
|
132
|
+
* role on the cluster, PVE's own built-ins included. Adoption is an explicit act.
|
|
133
|
+
*/
|
|
134
|
+
list: () => Effect.succeed([]),
|
|
135
|
+
read: Effect.fn(function* ({ olds }) {
|
|
136
|
+
return dropUnreadable(yield* readRole(olds));
|
|
137
|
+
}),
|
|
138
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
139
|
+
if (!isResolved(news)) return undefined;
|
|
140
|
+
yield* guardWrite(ROLE_CREATE, createForm(news), output === undefined);
|
|
141
|
+
yield* guardWrite(ROLE_UPDATE, updateForm(news), false);
|
|
142
|
+
if (output === undefined) return undefined;
|
|
143
|
+
const live = yield* readRole(news);
|
|
144
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
145
|
+
// `update` on a role that was plainly there — see unreadable-read.ts.
|
|
146
|
+
if (live === UNREADABLE) {
|
|
147
|
+
yield* unreadableWarning('Proxmox.Role', news.roleid);
|
|
148
|
+
return { action: 'noop' } as const;
|
|
149
|
+
}
|
|
150
|
+
if (live === undefined) {
|
|
151
|
+
yield* guardWrite(ROLE_CREATE, createForm(news), true);
|
|
152
|
+
return { action: 'update' } as const;
|
|
153
|
+
}
|
|
154
|
+
return matches(live, news)
|
|
155
|
+
? ({ action: 'noop' } as const)
|
|
156
|
+
: ({ action: 'update' } as const);
|
|
157
|
+
}),
|
|
158
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
159
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
160
|
+
// write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
|
|
161
|
+
// handles — and a wrongful create in that race fails loudly ("already exists")
|
|
162
|
+
// rather than silently duplicating, group.ts's reconcile has the same note.
|
|
163
|
+
const before = dropUnreadable(yield* readRole(news));
|
|
164
|
+
yield* guardWrite(ROLE_CREATE, createForm(news), before === undefined);
|
|
165
|
+
yield* guardWrite(ROLE_UPDATE, updateForm(news), false);
|
|
166
|
+
if (before === undefined) {
|
|
167
|
+
yield* runPve(
|
|
168
|
+
news.target,
|
|
169
|
+
'provision',
|
|
170
|
+
true,
|
|
171
|
+
access.createAccessRole(createForm(news)),
|
|
172
|
+
);
|
|
173
|
+
} else if (!matches(before, news)) {
|
|
174
|
+
yield* runPve(news.target, 'provision', true, access.putAccessRole(updateForm(news)));
|
|
175
|
+
}
|
|
176
|
+
const after = dropUnreadable(yield* readRole(news));
|
|
177
|
+
if (after === undefined) {
|
|
178
|
+
return yield* Effect.die(
|
|
179
|
+
new Error(
|
|
180
|
+
`access/roles/${news.roleid}: the write returned no error but the role is ` +
|
|
181
|
+
'still absent. PVE wraps every answer in {"data":...} and can report success ' +
|
|
182
|
+
'on a call that did nothing -- read back rather than trusting the status code.',
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return after;
|
|
187
|
+
}),
|
|
188
|
+
/**
|
|
189
|
+
* ⛔ DO NOT CARRY resource.ts's DESTROY REASSURANCE OVER TO THIS ONE. PVE does not refuse
|
|
190
|
+
* to delete a role still in use — whoever is bound to it loses those privileges the
|
|
191
|
+
* moment it goes, and the credential THIS PROVIDER runs with is bound through exactly
|
|
192
|
+
* such a binding. Checking who holds a role before removing it is an operator's job.
|
|
193
|
+
*/
|
|
194
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
195
|
+
yield* runPve(
|
|
196
|
+
olds.target,
|
|
197
|
+
'provision',
|
|
198
|
+
true,
|
|
199
|
+
access.deleteAccessRole({ roleid: olds.roleid }),
|
|
200
|
+
);
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cries-wolf fix (unreadable-read.ts), proved end to end for `Proxmox.Acl` — the reference
|
|
3
|
+
* migrated family — through Alchemy's own Plan and Apply, over a fake cluster whose OpenBao mint
|
|
4
|
+
* can be flipped to DENY the `provision` role mid-test, the way the agent lane's policy denies it
|
|
5
|
+
* for real (measured 2026-09-24, decision doc's "cries wolf" entry: 14 ACLs read back as absent
|
|
6
|
+
* and forced `update` with nothing compared).
|
|
7
|
+
*
|
|
8
|
+
* ★ ONE ENGINE, ONE TRANSPORT, A MUTABLE DENIAL FLAG — not two fakes — because the state row this
|
|
9
|
+
* test needs (`output !== undefined`, the exact case the bug forced `update` for) has to
|
|
10
|
+
* survive from the first deploy (mint allowed) into the second plan (mint denied);
|
|
11
|
+
* fake-engine.ts builds its in-memory state store fresh per `engineOver` call, so the same
|
|
12
|
+
* engine instance is reused for both, and only the fake OpenBao's answer to `/creds/provision`
|
|
13
|
+
* changes in between. `lease_duration: 0` on every fake mint (both branches) keeps
|
|
14
|
+
* lease-cache.ts cold, so the second phase genuinely re-mints and hits the denial.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import * as Layer from 'effect/Layer';
|
|
18
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
19
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
20
|
+
import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
21
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
22
|
+
|
|
23
|
+
type Row = { path: string; type: string; ugid: string; roleid: string; propagate?: number };
|
|
24
|
+
|
|
25
|
+
/** One ACL row, plus an OpenBao mint the test can deny for `provision` mid-run. */
|
|
26
|
+
const clusterDenyingProvision = (rows: readonly Row[]) => {
|
|
27
|
+
let denyProvision = false;
|
|
28
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
29
|
+
const request =
|
|
30
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
if (url.pathname.includes('/creds/')) {
|
|
33
|
+
if (denyProvision && url.pathname.endsWith('/creds/provision')) {
|
|
34
|
+
return Response.json({ errors: ['permission denied'] }, { status: 403 });
|
|
35
|
+
}
|
|
36
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
37
|
+
return Response.json({ data, lease_duration: 0 });
|
|
38
|
+
}
|
|
39
|
+
const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
|
|
40
|
+
if (request.method === 'GET' && path === 'access/acl') return Response.json({ data: rows });
|
|
41
|
+
// ⛔ Any other write is a bug this test would otherwise hide: once denied, `reconcile` must
|
|
42
|
+
// never be reached (diff answers noop), so nothing should ever PUT here again.
|
|
43
|
+
return Response.json({ data: null });
|
|
44
|
+
};
|
|
45
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
46
|
+
return {
|
|
47
|
+
deny: () => {
|
|
48
|
+
denyProvision = true;
|
|
49
|
+
},
|
|
50
|
+
layer: FetchHttpClient.layer.pipe(
|
|
51
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const grant = () =>
|
|
57
|
+
ProxmoxAcl('auditor', {
|
|
58
|
+
path: '/',
|
|
59
|
+
roleid: 'PVEAuditor',
|
|
60
|
+
target: FAKE_TARGET,
|
|
61
|
+
type: 'user',
|
|
62
|
+
ugid: 'u@pve',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('the cries-wolf fix: a refused provision mint reports noop, never update', () => {
|
|
66
|
+
test('an already-correct grant plans noop once the credential is denied, and nothing is written', async () => {
|
|
67
|
+
const rows: Row[] = [
|
|
68
|
+
{ path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
|
|
69
|
+
];
|
|
70
|
+
const fake = clusterDenyingProvision(rows);
|
|
71
|
+
await withoutBao(async () => {
|
|
72
|
+
const engine = engineOver(ProxmoxAclProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
73
|
+
// First deploy: mint allowed, adopts the matching grant and records a state row.
|
|
74
|
+
expect(await engine.deploy(grant())).toEqual({ auditor: 'adopted' });
|
|
75
|
+
// Now the credential's role is denied -- exactly the measured agent-lane scenario.
|
|
76
|
+
fake.deny();
|
|
77
|
+
const report = await engine.verify(grant(), { all: true });
|
|
78
|
+
// ⛔ THE BUG THIS PROVES FIXED: before the fix this row read 'update' with nothing compared.
|
|
79
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
80
|
+
expect(await engine.deploy(grant())).toEqual({ auditor: 'noop' });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "unreadable" outcome a refused read produces — distinct from `undefined` (genuinely
|
|
3
|
+
* absent) and from a real attributes object (present) — so `diff` stops treating a REFUSED read
|
|
4
|
+
* the same as a confirmed-gone one. Fixes the "cries wolf" bug (measured 2026-09-24): on a lane
|
|
5
|
+
* whose credential cannot mint `provision`, every already-correct ACL and storage row read back
|
|
6
|
+
* as absent, and `diff` forced `update` for every one of them without comparing a single field,
|
|
7
|
+
* because `.pipe(Effect.orElseSucceed(() => undefined))` folds every failure — a genuine 404, a
|
|
8
|
+
* network blip, AND a permission refusal — into the one "absent" value `diff` cannot tell apart.
|
|
9
|
+
*
|
|
10
|
+
* ★ WHY A SENTINEL, NOT A FAILED `diff`. Upstream's `Diff` type is exactly
|
|
11
|
+
* `NoopDiff | UpdateDiff | ReplaceDiff` (`packages/alchemy/src/Diff.ts@v2.0.0-beta.79`) — there
|
|
12
|
+
* is no fourth "unreadable" action to return. And `Plan.ts`'s resource pass runs every
|
|
13
|
+
* resource's `diff` under `Effect.exit`, then FAILS THE WHOLE PLAN if even one failed
|
|
14
|
+
* (`diffFailures.length > 0` -> `Effect.failCause`, `Plan.ts@v2.0.0-beta.79` ~lines 1711-1731).
|
|
15
|
+
* So making `diff` FAIL on a refused read would not report one honest row — it would abort
|
|
16
|
+
* `bun run plan` for every OTHER resource in the stack too, worse than today's false "update".
|
|
17
|
+
* `{ action: 'noop' }` plus a logged warning is the only outcome that (a) never forces a write
|
|
18
|
+
* on a lane that cannot prove one is needed and (b) never takes the rest of the plan down with
|
|
19
|
+
* it — the "noop-with-warning" reading of the fix, over the alternative of a typed plan error.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Every other read failure (a genuine
|
|
22
|
+
* `Cause.TimeoutError`, `PveClusterExhausted`, a network error) keeps folding into `undefined`
|
|
23
|
+
* exactly as it always has — this narrows ONE typed tag out of that fold, via `catchTag`, and
|
|
24
|
+
* changes nothing else about it. See credential-errors.ts's own ⛔ for why 403 alone qualifies.
|
|
25
|
+
*/
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import type { PveCredentialDenied } from './credential-errors.ts';
|
|
28
|
+
|
|
29
|
+
/** Distinct from `undefined` (absent) and from a real attributes object (present). */
|
|
30
|
+
export const UNREADABLE = Symbol('pve-unreadable');
|
|
31
|
+
export type Unreadable = typeof UNREADABLE;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Catches ONLY `PveCredentialDenied` and turns it into the sentinel, so a caller that also
|
|
35
|
+
* `.pipe(Effect.orElseSucceed(() => undefined))` for every OTHER failure never conflates a
|
|
36
|
+
* refusal with a genuine absence. Compose this INSIDE that fold, never after it.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ `E | PveCredentialDenied` IN, `E` OUT — not a generic `E` narrowed by `Exclude`. `catchTag`
|
|
39
|
+
* needs the tag to be STATICALLY present in the error union to typecheck at all; a bare
|
|
40
|
+
* unconstrained `E` gives it nothing to match against. Every caller's `runPve` already carries
|
|
41
|
+
* `PveCredentialDenied` explicitly (distilled-pve.ts's own ★ on why), so this signature costs
|
|
42
|
+
* nothing at the call site.
|
|
43
|
+
*/
|
|
44
|
+
export const readOrUnreadable = <A, E, R>(
|
|
45
|
+
effect: Effect.Effect<A, E | PveCredentialDenied, R>,
|
|
46
|
+
): Effect.Effect<A | Unreadable, E, R> =>
|
|
47
|
+
effect.pipe(Effect.catchTag('PveCredentialDenied', () => Effect.succeed(UNREADABLE)));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The warning `diff` logs when it reports `noop` for a row it could not actually read — visible
|
|
51
|
+
* in `bun run plan`'s own output (Effect's default logger), even though the Diff type has
|
|
52
|
+
* nowhere to carry the distinction through to the printed action. See the header for why a log
|
|
53
|
+
* line, not a failed diff or a fourth Diff action, is what "the plan reports" means here.
|
|
54
|
+
*/
|
|
55
|
+
export const unreadableWarning = (type: string, identity: string): Effect.Effect<void> =>
|
|
56
|
+
Effect.logWarning(
|
|
57
|
+
`${type} ${identity}: the read was refused (no grant for this credential's role) -- ` +
|
|
58
|
+
'reporting noop rather than forcing an update with nothing compared. Re-plan on a lane ' +
|
|
59
|
+
'that can read this object before trusting this row.',
|
|
60
|
+
);
|