@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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Group` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
|
|
3
|
+
* `@distilled.cloud/proxmox` protocol — the family's own create/drift/retain/destroy paths;
|
|
4
|
+
* adopt-noop.test.ts owns the cross-family "adopting a matching object only reads" proof.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
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 { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
13
|
+
|
|
14
|
+
type Live = { comment: string | undefined; members: string[] };
|
|
15
|
+
|
|
16
|
+
/** `access/groups` (POST) and `access/groups/{groupid}` (GET/PUT/DELETE), keyed by groupid. */
|
|
17
|
+
const cluster = (groups: Map<string, Live>) =>
|
|
18
|
+
fakePve((call: PveCall) => {
|
|
19
|
+
const match = /^access\/groups(?:\/(.+))?$/.exec(call.path);
|
|
20
|
+
if (match === null) return undefined;
|
|
21
|
+
const groupid = match[1];
|
|
22
|
+
if (call.method === 'GET' && groupid !== undefined) return groups.get(groupid);
|
|
23
|
+
if (call.method === 'POST' && groupid === undefined) {
|
|
24
|
+
groups.set(call.form['groupid'] ?? '', { comment: call.form['comment'], members: [] });
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (call.method === 'PUT' && groupid !== undefined) {
|
|
28
|
+
const live = groups.get(groupid);
|
|
29
|
+
if (live !== undefined) groups.set(groupid, { ...live, comment: call.form['comment'] });
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (call.method === 'DELETE' && groupid !== undefined) {
|
|
33
|
+
groups.delete(groupid);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const declare = (comment?: string) =>
|
|
40
|
+
ProxmoxGroup('mint', {
|
|
41
|
+
groupid: 'hf-mint',
|
|
42
|
+
target: FAKE_TARGET,
|
|
43
|
+
...(comment === undefined ? {} : { comment }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
47
|
+
engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
48
|
+
|
|
49
|
+
describe('Proxmox.Group over the distilled protocol', () => {
|
|
50
|
+
test('creating a group that does not exist yet: one POST', async () => {
|
|
51
|
+
const groups = new Map<string, Live>();
|
|
52
|
+
const fake = cluster(groups);
|
|
53
|
+
await withoutBao(async () => {
|
|
54
|
+
const engine = engineFor(fake);
|
|
55
|
+
expect(await engine.deploy(declare('fence'))).toEqual({ mint: 'create' });
|
|
56
|
+
});
|
|
57
|
+
expect(fake.writes()).toEqual(['POST access/groups']);
|
|
58
|
+
expect(groups.get('hf-mint')).toEqual({ comment: 'fence', members: [] });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a drifted comment is corrected with exactly one PUT', async () => {
|
|
62
|
+
const groups = new Map<string, Live>([['hf-mint', { comment: 'old', members: [] }]]);
|
|
63
|
+
const fake = cluster(groups);
|
|
64
|
+
await withoutBao(async () => {
|
|
65
|
+
const engine = engineFor(fake);
|
|
66
|
+
const report = await engine.verify(declare('fence'));
|
|
67
|
+
expect(report.rows[0]).toMatchObject({ diff: 'update' });
|
|
68
|
+
expect(await engine.deploy(declare('fence'))).toEqual({ mint: 'adopted' });
|
|
69
|
+
});
|
|
70
|
+
expect(fake.writes()).toEqual(['PUT access/groups/hf-mint']);
|
|
71
|
+
expect(groups.get('hf-mint')?.comment).toBe('fence');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ⛔ PVE cannot store the literal comment '0' (group.ts's header) — a declared '0' must read
|
|
75
|
+
// back as noop against a live '' rather than diffing forever.
|
|
76
|
+
test("a declared comment of '0' matches a live empty comment (PVE's own truthiness trap)", async () => {
|
|
77
|
+
const groups = new Map<string, Live>([['hf-mint', { comment: '', members: [] }]]);
|
|
78
|
+
const fake = cluster(groups);
|
|
79
|
+
await withoutBao(async () => {
|
|
80
|
+
const engine = engineFor(fake);
|
|
81
|
+
const report = await engine.verify(declare('0'));
|
|
82
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
83
|
+
});
|
|
84
|
+
expect(fake.writes()).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('undeclaring sends no DELETE (retain is the default)', async () => {
|
|
88
|
+
const groups = new Map<string, Live>();
|
|
89
|
+
const fake = cluster(groups);
|
|
90
|
+
await withoutBao(async () => {
|
|
91
|
+
const engine = engineFor(fake);
|
|
92
|
+
await engine.deploy(declare('fence'));
|
|
93
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
94
|
+
});
|
|
95
|
+
expect(fake.writes()).toEqual(['POST access/groups']);
|
|
96
|
+
expect(groups.has('hf-mint')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('RemovalPolicy.destroy() sends the DELETE', async () => {
|
|
100
|
+
const groups = new Map<string, Live>();
|
|
101
|
+
const fake = cluster(groups);
|
|
102
|
+
await withoutBao(async () => {
|
|
103
|
+
const engine = engineFor(fake);
|
|
104
|
+
await engine.deploy(declare('fence').pipe(RemovalPolicy.destroy()));
|
|
105
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
106
|
+
});
|
|
107
|
+
expect(fake.writes()).toEqual(['POST access/groups', 'DELETE access/groups/hf-mint']);
|
|
108
|
+
expect(groups.has('hf-mint')).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/proxmox/group.ts
CHANGED
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
* "users":"hf-provision@pve,hf-read@pve"}, …]
|
|
23
23
|
* GET /access/groups/hf-mint -> {"comment":"…","members":["hf-provision@pve","hf-read@pve"]}
|
|
24
24
|
* The index says `users` and hands back a COMMA STRING; the item says `members` and hands back an
|
|
25
|
-
* ARRAY.
|
|
26
|
-
*
|
|
25
|
+
* ARRAY. distilled's generator reflects the SAME split (`GetAccessGroupResponse.members: string[]`
|
|
26
|
+
* vs `ListAccessGroupsResponseBodyItem.users?: string`), so reading the item is what keeps this
|
|
27
|
+
* file on the array shape it already normalises.
|
|
27
28
|
*
|
|
28
29
|
* ⛔ AND THE ITEM'S ORDER IS NOT STABLE BETWEEN TWO CONSECUTIVE CALLS. MEASURED, seconds apart, on
|
|
29
30
|
* the same endpoint:
|
|
@@ -38,37 +39,56 @@
|
|
|
38
39
|
* exactly `comment` and `members`, so the id comes from props — the same arrangement, and the
|
|
39
40
|
* same reason, as `user.ts`.
|
|
40
41
|
*
|
|
41
|
-
* ★ PRIVILEGES: NOTHING HAD TO BE WIDENED
|
|
42
|
-
* NOTHING. Measured with `pveum user permissions`, 2026-09-13:
|
|
42
|
+
* ★ PRIVILEGES: NOTHING HAD TO BE WIDENED. Measured with `pveum user permissions`, 2026-09-13:
|
|
43
43
|
* · item GET checks `['perm','/access/groups',['Sys.Audit','Group.Allocate'], any => 1]`, and
|
|
44
|
-
* `hf-read@pve` already holds `Sys.Audit` there
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* `hf-read@pve` already holds `Sys.Audit` there. So `readAttributes` below reads with the
|
|
45
|
+
* default `read` role — unlike acl.ts, user.ts and api-token.ts, whose item/list reads PVE
|
|
46
|
+
* gates on an allocate privilege the 3600s read lease does not hold.
|
|
47
47
|
* · POST, PUT and DELETE each check `Group.Allocate` on `/access/groups`, and
|
|
48
48
|
* `hf-provision@pve` holds it via the provision role (`PROVISION_PRIVILEGES`).
|
|
49
49
|
*
|
|
50
50
|
* ⛔ `retain` BY DEFAULT, BECAUSE DELETING A GROUP DESTROYS TWO THINGS THIS FILE CANNOT PUT BACK.
|
|
51
51
|
* Measured in `PVE::AccessControl` on the node:
|
|
52
|
-
* · The member list IS the group. `user.cfg` stores it on the GROUP line
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* · The member list IS the group. `user.cfg` stores it on the GROUP line and DERIVES each
|
|
53
|
+
* user's `groups` map from it at parse time. Deleting the group deletes the list, and this
|
|
54
|
+
* resource has no `members` prop to restore it from.
|
|
55
55
|
* · `delete_group_acl` then walks the whole ACL tree and drops every grant where the group is
|
|
56
56
|
* the SUBJECT. On this cluster that is `admins -> Administrator on /` and
|
|
57
57
|
* `automation -> PVEAuditor on /`: whole populations of access, gone in one call, with no
|
|
58
58
|
* confirmation and nothing in the plan to suggest it.
|
|
59
|
-
* `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists
|
|
60
|
-
*
|
|
61
|
-
* `resource.ts` for why the policy is a guard rather than a stubbed-out operation.
|
|
59
|
+
* `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists — so
|
|
60
|
+
* `.pipe(RemovalPolicy.destroy())` really removes the group.
|
|
62
61
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` ONTO `@distilled.cloud/proxmox`'s
|
|
63
|
+
* typed `access.getAccessGroup`/`createAccessGroup`/`putAccessGroup`/`deleteAccessGroup`
|
|
64
|
+
* (2026-09-24, decision 43's proxmox walk-down, access sub-area, second resource after Acl).
|
|
65
|
+
* `distilled-pve.ts`'s `runPve` replaces `pve()` as in acl.ts; the CRIES-WOLF FIX
|
|
66
|
+
* (unreadable-read.ts) is wired the same way: a refused `provision` mint on the WRITE-side
|
|
67
|
+
* guard, or a refused `read` mint on the plain read, reports `noop` with a warning rather than
|
|
68
|
+
* forcing `update` with nothing compared.
|
|
66
69
|
*/
|
|
67
70
|
import { Resource } from 'alchemy';
|
|
71
|
+
import { isResolved } from 'alchemy/Diff';
|
|
68
72
|
import * as Provider from 'alchemy/Provider';
|
|
73
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
69
74
|
import * as Effect from 'effect/Effect';
|
|
70
|
-
import {
|
|
71
|
-
import {
|
|
75
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
76
|
+
import {
|
|
77
|
+
GROUP_CREATE,
|
|
78
|
+
GROUP_UPDATE,
|
|
79
|
+
attributesOf,
|
|
80
|
+
createForm,
|
|
81
|
+
matches,
|
|
82
|
+
updateForm,
|
|
83
|
+
} from './group-wire.ts';
|
|
84
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
85
|
+
import { runPve } from './distilled-pve.ts';
|
|
86
|
+
import {
|
|
87
|
+
UNREADABLE,
|
|
88
|
+
type Unreadable,
|
|
89
|
+
readOrUnreadable,
|
|
90
|
+
unreadableWarning,
|
|
91
|
+
} from './unreadable-read.ts';
|
|
72
92
|
|
|
73
93
|
export interface GroupProps extends WithTarget {
|
|
74
94
|
/**
|
|
@@ -108,94 +128,104 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
|
|
|
108
128
|
});
|
|
109
129
|
|
|
110
130
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* the config parser: `$cfg->{groups}->{$g}->{comment} = decode_text($comment) if $comment;`
|
|
119
|
-
* So a declared `comment: '0'` is accepted by the API, dropped on the way to `user.cfg`, and read
|
|
120
|
-
* back as absent. Left alone, `matches` would be false on every plan and every deploy would
|
|
121
|
-
* rewrite the same value into the same hole. Normalising BOTH the comparison and the form is how
|
|
122
|
-
* `values.ts` already treats `''` as the absence of a boolean rather than as `false`.
|
|
123
|
-
* ⚠️ MEASURED IN THE SOURCE ON node-b, NOT ON THE WIRE — writing `'0'` to the cluster to watch it
|
|
124
|
-
* vanish would have been a write, and this agent had read access only.
|
|
125
|
-
* ⚠️ `pool.ts` HAS THE SAME LATENT HOLE — `pool:$pool:$comment:…` is written by that same
|
|
126
|
-
* truthiness test and is not guarded. Not fixed from here; flagged so it is fixed on purpose.
|
|
127
|
-
*
|
|
128
|
-
* ⚠️ EVERY OTHER COMMENT ROUND-TRIPS EXACTLY, colons and newlines included: `write_user_config`
|
|
129
|
-
* runs it through `encode_text` and the parser through `decode_text`, so the field separator in
|
|
130
|
-
* `user.cfg` cannot be smuggled in.
|
|
131
|
+
* ★ Default `read` role — nothing widened for this family, see the header's privileges note.
|
|
132
|
+
* ⛔ `live.members === undefined` MEANS ABSENT, NOT AN EMPTY GROUP. `@distilled.cloud/core`'s
|
|
133
|
+
* REST protocol unwraps PVE's `{"data": null}` (a genuinely missing object — MEASURED in
|
|
134
|
+
* `protocol.ts`: "a Unit-output endpoint... `data` is null/absent") to `{}` BEFORE the
|
|
135
|
+
* schema-driven decode, not to a decode failure. `members` is the one REQUIRED field on this
|
|
136
|
+
* response, so its absence is the only signal that the group was never there — the same trap
|
|
137
|
+
* api-token-form.ts's `attributes` guards with its own required-field check.
|
|
131
138
|
*/
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
const readGroup = (props: GroupProps) =>
|
|
140
|
+
readOrUnreadable(runPve(props.target, 'read', false, access.getAccessGroup(props))).pipe(
|
|
141
|
+
Effect.map((live) =>
|
|
142
|
+
live === UNREADABLE
|
|
143
|
+
? UNREADABLE
|
|
144
|
+
: live.members === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: attributesOf(live, props),
|
|
147
|
+
),
|
|
148
|
+
Effect.orElseSucceed(() => undefined),
|
|
149
|
+
);
|
|
136
150
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
* ⚠️ `csv` IS REUSED RATHER THAN RE-IMPLEMENTED, AND ITS OWN ⚠️ IS EXACTLY THIS CASE: a list PVE
|
|
141
|
-
* does not promise to give back in the order it was handed. Going out through the joined form
|
|
142
|
-
* and back is the price of that reuse, and cheaper than a fourth hand-rolled sorter.
|
|
143
|
-
* ⚠️ THE ARRAY ONLY — THE COMMA STRING IS NOT ACCEPTED HERE, ON PURPOSE. That spelling belongs to
|
|
144
|
-
* the INDEX (`users`), which this provider never reads, so a branch for it would be a guess at a
|
|
145
|
-
* shape that cannot arrive. `user.ts` refuses the same guess for `tokens` and for the same
|
|
146
|
-
* reason: a wrong guess on a reported-only field surfaces in a plan as a phantom membership.
|
|
147
|
-
* ⚠️ NO DEDUPLICATION, UNLIKE `user.ts`'s `groupSet`. These names are Perl HASH KEYS —
|
|
148
|
-
* `[keys %{ $data->{users} }]` — so a duplicate is not representable. `user.groups` needs the
|
|
149
|
-
* dedupe because its value also arrives from a hand-written prop; this one never does.
|
|
150
|
-
*/
|
|
151
|
-
const memberList = (live: unknown): string[] => {
|
|
152
|
-
const joined = csv((Array.isArray(live) ? live : []).map((member: unknown) => text(member)));
|
|
153
|
-
return joined === '' ? [] : joined.split(',');
|
|
154
|
-
};
|
|
151
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
152
|
+
const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
153
|
+
live === UNREADABLE ? undefined : live;
|
|
155
154
|
|
|
156
|
-
const handlers = pveHandlers<GroupProps, GroupAttributes>({
|
|
157
|
-
attributes: (live, props) => ({
|
|
158
|
-
/**
|
|
159
|
-
* ⚠️ ABSENT IS EMPTY, NOT MISSING. `read_group` sets `comment` only `if defined(…)`, so a group
|
|
160
|
-
* without one answers `{"members":[…]}` and nothing else. THAT ONE IS READ OFF THE HANDLER'S
|
|
161
|
-
* SOURCE, not off the wire — every group on this cluster happens to carry a comment, so the
|
|
162
|
-
* commentless shape was not there to measure. The empty-MEMBERS shape WAS measured —
|
|
163
|
-
* `automation` answers `{"comment":"…","members":[]}`. `''` is what an undeclared
|
|
164
|
-
* `comment` prop normalises to, so the two sides meet either way.
|
|
165
|
-
*/
|
|
166
|
-
comment: text(live['comment']),
|
|
167
|
-
/** ⚠️ FROM PROPS: the item read's schema is `additionalProperties: 0` over comment/members. */
|
|
168
|
-
groupid: props.groupid,
|
|
169
|
-
members: memberList(live['members']),
|
|
170
|
-
}),
|
|
171
|
-
collection: () => 'access/groups',
|
|
172
|
-
createForm: (props) => ({ comment: storedComment(props.comment), groupid: props.groupid }),
|
|
173
|
-
/**
|
|
174
|
-
* ⚠️ `members` IS NOT COMPARED, AND NOTHING ELSE IS LEFT TO COMPARE. `comment` is the only field
|
|
175
|
-
* a PUT here accepts, so it is the only field whose drift this provider can repair — the rule
|
|
176
|
-
* `resource.ts` states once in the ⛔ on adoption: a field deliberately out of `matches` is a
|
|
177
|
-
* field this resource does not manage.
|
|
178
|
-
*/
|
|
179
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
180
|
-
endpoint: { create: 'pve:POST /access/groups', update: 'pve:PUT /access/groups/{groupid}' },
|
|
181
|
-
matches: (attributes, props) => attributes.comment === storedComment(props.comment),
|
|
182
|
-
path: (props) => `access/groups/${props.groupid}`,
|
|
183
|
-
/**
|
|
184
|
-
* ⚠️ ALWAYS SENT, EVEN EMPTY — that is how a comment is CLEARED. `update_group` assigns
|
|
185
|
-
* `if defined($param->{comment})`, so an omitted field leaves the old text in place and a
|
|
186
|
-
* declaration that dropped its comment would report an update the update could not settle. The
|
|
187
|
-
* empty string then falls out of `user.cfg` on the truthiness test above and reads back as
|
|
188
|
-
* `''`, which is what the props side normalises to. The loop closes.
|
|
189
|
-
*/
|
|
190
|
-
updateForm: (props) => ({ comment: storedComment(props.comment) }),
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* ⛔ THE EMPTY `list` IS INHERITED FROM `pveHandlers` AND IT MATTERS HERE. `GET /access/groups`
|
|
195
|
-
* answers with every group on the cluster — on this one that is `admins`, which grants
|
|
196
|
-
* `Administrator` on `/` to every human who logs in. Handing it to Alchemy would invite adoption,
|
|
197
|
-
* and therefore one day a delete that takes the whole admin group's access with it. Adoption
|
|
198
|
-
* stays an explicit act, here as everywhere else in this package.
|
|
199
|
-
*/
|
|
200
155
|
export const ProxmoxGroupProvider = () =>
|
|
201
|
-
Provider.effect(
|
|
156
|
+
Provider.effect(
|
|
157
|
+
ProxmoxGroup,
|
|
158
|
+
Effect.succeed(
|
|
159
|
+
ProxmoxGroup.Provider.of({
|
|
160
|
+
/**
|
|
161
|
+
* ⛔ THE EMPTY `list` MATTERS HERE. `GET /access/groups` answers every group on the
|
|
162
|
+
* cluster, `admins` (grants `Administrator` on `/`) included. Adoption stays explicit.
|
|
163
|
+
*/
|
|
164
|
+
list: () => Effect.succeed([]),
|
|
165
|
+
read: Effect.fn(function* ({ olds }) {
|
|
166
|
+
return dropUnreadable(yield* readGroup(olds));
|
|
167
|
+
}),
|
|
168
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
169
|
+
if (!isResolved(news)) return undefined;
|
|
170
|
+
yield* guardWrite(GROUP_CREATE, createForm(news), output === undefined);
|
|
171
|
+
yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
|
|
172
|
+
if (output === undefined) return undefined;
|
|
173
|
+
const live = yield* readGroup(news);
|
|
174
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
175
|
+
// `update` on a group that was plainly there — see unreadable-read.ts.
|
|
176
|
+
if (live === UNREADABLE) {
|
|
177
|
+
yield* unreadableWarning('Proxmox.Group', news.groupid);
|
|
178
|
+
return { action: 'noop' } as const;
|
|
179
|
+
}
|
|
180
|
+
if (live === undefined) {
|
|
181
|
+
// Drift: state says this group exists, the cluster disagrees. reconcile recreates it.
|
|
182
|
+
yield* guardWrite(GROUP_CREATE, createForm(news), true);
|
|
183
|
+
return { action: 'update' } as const;
|
|
184
|
+
}
|
|
185
|
+
return matches(live, news)
|
|
186
|
+
? ({ action: 'noop' } as const)
|
|
187
|
+
: ({ action: 'update' } as const);
|
|
188
|
+
}),
|
|
189
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
190
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
191
|
+
// write below (same lease-cache key), so `UNREADABLE` here is a narrow race, not the
|
|
192
|
+
// routine case `diff` handles. ⛔ UNLIKE acl.ts's PUT-only bind, a group's create is
|
|
193
|
+
// NOT idempotent the same way: if this narrow race DOES land as `undefined` while the
|
|
194
|
+
// group is genuinely already there, PVE answers "group already exists" rather than
|
|
195
|
+
// silently converging — a loud failure, not a silent duplicate.
|
|
196
|
+
const before = dropUnreadable(yield* readGroup(news));
|
|
197
|
+
yield* guardWrite(GROUP_CREATE, createForm(news), before === undefined);
|
|
198
|
+
yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
|
|
199
|
+
if (before === undefined) {
|
|
200
|
+
yield* runPve(
|
|
201
|
+
news.target,
|
|
202
|
+
'provision',
|
|
203
|
+
true,
|
|
204
|
+
access.createAccessGroup(createForm(news)),
|
|
205
|
+
);
|
|
206
|
+
} else if (!matches(before, news)) {
|
|
207
|
+
yield* runPve(news.target, 'provision', true, access.putAccessGroup(updateForm(news)));
|
|
208
|
+
}
|
|
209
|
+
const after = dropUnreadable(yield* readGroup(news));
|
|
210
|
+
if (after === undefined) {
|
|
211
|
+
return yield* Effect.die(
|
|
212
|
+
new Error(
|
|
213
|
+
`access/groups/${news.groupid}: the write returned no error but the group is ` +
|
|
214
|
+
'still absent. PVE wraps every answer in {"data":...} and can report success ' +
|
|
215
|
+
'on a call that did nothing -- read back rather than trusting the status code.',
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return after;
|
|
220
|
+
}),
|
|
221
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
222
|
+
yield* runPve(
|
|
223
|
+
olds.target,
|
|
224
|
+
'provision',
|
|
225
|
+
true,
|
|
226
|
+
access.deleteAccessGroup({ groupid: olds.groupid }),
|
|
227
|
+
);
|
|
228
|
+
}),
|
|
229
|
+
}),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
@@ -47,7 +47,8 @@ import * as Cache from 'effect/Cache';
|
|
|
47
47
|
import * as Duration from 'effect/Duration';
|
|
48
48
|
import * as Effect from 'effect/Effect';
|
|
49
49
|
import * as Exit from 'effect/Exit';
|
|
50
|
-
import { type ApiTarget, type PveCredential, type PveRole
|
|
50
|
+
import { type ApiTarget, type PveCredential, type PveRole } from './credentials.ts';
|
|
51
|
+
import { mint } from './mint.ts';
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* Re-mint once a lease is inside this much of its end, in seconds.
|
|
@@ -27,7 +27,8 @@ import * as Effect from 'effect/Effect';
|
|
|
27
27
|
import { PveError, pve, pveWith } from './client.ts';
|
|
28
28
|
import { guardForm } from './constraint-guard.ts';
|
|
29
29
|
import type { EndpointKey } from './constraints.ts';
|
|
30
|
-
import {
|
|
30
|
+
import type { PveTarget } from './credentials.ts';
|
|
31
|
+
import { mint } from './mint.ts';
|
|
31
32
|
import { createForm } from './lxc-create-form.ts';
|
|
32
33
|
import { type LxcChange, judge } from './lxc-judge.ts';
|
|
33
34
|
import type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<role>`, alive for five
|
|
3
|
+
* minutes. Split out of `credentials.ts` (2026-09-24) to keep that file's TYPES under the
|
|
4
|
+
* 250-line cap once `PveCredentialDenied`'s catchers needed a real header there; the reasoning
|
|
5
|
+
* for WHY the estate mints rather than stores a token is still credentials.ts's own header.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE SECRET NEVER TOUCHES DISK, A LOG, OR ALCHEMY STATE — see credentials.ts's ⛔. Nothing
|
|
8
|
+
* here returns it to a resource; it is used to build a header and then dropped.
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Option from 'effect/Option';
|
|
12
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
13
|
+
import * as Headers from 'effect/unstable/http/Headers';
|
|
14
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
15
|
+
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
16
|
+
import { PveCredentialDenied } from './credential-errors.ts';
|
|
17
|
+
import type { ApiTarget, BaoEnvironment, PveCredential, PveRole } from './credentials.ts';
|
|
18
|
+
|
|
19
|
+
/** openbao v2.6.2 api/env.go:22-34 — a present BAO_* wins, even empty; else its VAULT_* twin. */
|
|
20
|
+
const baoVariable = (env: BaoEnvironment, name: string) =>
|
|
21
|
+
env[`BAO_${name}`] ?? env[`VAULT_${name}`] ?? '';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mint one credential for `role`: `GET /v1/<mount>/creds/<role>` on OpenBao's HTTP API.
|
|
25
|
+
*
|
|
26
|
+
* 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
|
|
27
|
+
* client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
|
|
28
|
+
* learned twice what deciding by stderr costs — every failure read as absent, and two commands
|
|
29
|
+
* said "absent" in different words — so this reads STATUS CODES: 2xx is a credential, and 403
|
|
30
|
+
* (denied), 404 (no such role or mount — a mint has no "absent"), 503 (sealed) all fail.
|
|
31
|
+
* ★ THE ENVIRONMENT CONTRACT IS STILL THE CLI's, READ THE CLI's WAY, which keeps the case above
|
|
32
|
+
* true (openbao v2.6.2 api/client.go:34-66, 370, 503-507, 761-762, 776-785): BAO_AGENT_ADDR over
|
|
33
|
+
* BAO_ADDR over https://127.0.0.1:8200; BAO_NAMESPACE as X-Vault-Namespace; BAO_TOKEN as
|
|
34
|
+
* X-Vault-Token; X-Vault-Request always. <estate>/openbao/src/bao-address.ts has the long form.
|
|
35
|
+
* ★ AGENT MODE IS NO BAO_TOKEN AND NO TOKEN HEADER. An Agent or Proxy listener with
|
|
36
|
+
* `api_proxy { use_auto_auth_token = true }` (or the older `cache { … }`) substitutes its
|
|
37
|
+
* auto-auth token exactly when a request carries none — command/agentproxyshared/cache/handler.go
|
|
38
|
+
* :29-38, command/agent.go:517-541, command/agent/config/config.go:288-292, and
|
|
39
|
+
* website/content/docs/agent-and-proxy/agent/apiproxy.mdx:57-62. The CLI's token-file fallback
|
|
40
|
+
* is deliberately not read. ⚠️ So an unset BAO_TOKEN aimed at a SERVER rather than an agent is a
|
|
41
|
+
* 403, which reads like a missing grant rather than a missing variable.
|
|
42
|
+
* ★ UNIX SOCKETS: `unix:///path` is plain HTTP to `localhost` over that socket, as the CLI does it
|
|
43
|
+
* (api/client.go:654-667). Bun's fetch takes a `unix` option; `FetchHttpClient.RequestInit` is
|
|
44
|
+
* Effect's documented way to pass fetch options, read from the calling fiber (effect
|
|
45
|
+
* FetchHttpClient.ts:59-72). Off Bun it REFUSES, rather than reach http://localhost:80.
|
|
46
|
+
*/
|
|
47
|
+
export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = process.env) =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const path = `${target.mount}/creds/${role}`;
|
|
50
|
+
// ⛔ METHOD, PATH AND OpenBao's `errors` ONLY — never a header (the token), never a success
|
|
51
|
+
// body (the credential is IN it, so echoing it would print the secret into a log).
|
|
52
|
+
const refuse = (status: number, detail: string) =>
|
|
53
|
+
new Error(`OpenBao GET /v1/${path} -> ${status === 0 ? 'no response' : status}: ${detail}`);
|
|
54
|
+
const address =
|
|
55
|
+
baoVariable(env, 'AGENT_ADDR') || baoVariable(env, 'ADDR') || 'https://127.0.0.1:8200';
|
|
56
|
+
const socket = address.startsWith('unix://') ? address.slice('unix://'.length) : undefined;
|
|
57
|
+
const base = socket === undefined ? address.replace(/\/+$/, '') : 'http://localhost';
|
|
58
|
+
const headers: Record<string, string> = { 'X-Vault-Request': 'true' };
|
|
59
|
+
const namespace = baoVariable(env, 'NAMESPACE');
|
|
60
|
+
if (namespace !== '') headers['X-Vault-Namespace'] = namespace;
|
|
61
|
+
const token = baoVariable(env, 'TOKEN');
|
|
62
|
+
if (token !== '') headers['X-Vault-Token'] = token;
|
|
63
|
+
if (socket !== undefined && !('Bun' in globalThis)) {
|
|
64
|
+
return yield* Effect.fail(refuse(0, 'a unix:// address needs Bun fetch; this is not Bun'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const client = yield* HttpClient.HttpClient;
|
|
68
|
+
// ⛔ Effect records request headers on the client span, and its default redaction list does
|
|
69
|
+
// not name x-vault-token (effect Headers.ts:753-762). It is added for this call.
|
|
70
|
+
const redacted = yield* Headers.CurrentRedactedNames;
|
|
71
|
+
const defaults = Option.getOrElse(
|
|
72
|
+
yield* Effect.serviceOption(FetchHttpClient.RequestInit),
|
|
73
|
+
() => ({}),
|
|
74
|
+
);
|
|
75
|
+
const request = HttpClientRequest.get(`${base}/v1/${path}`).pipe(
|
|
76
|
+
HttpClientRequest.setHeaders(headers),
|
|
77
|
+
);
|
|
78
|
+
const exchange = client.execute(request).pipe(
|
|
79
|
+
Effect.flatMap((response) =>
|
|
80
|
+
Effect.map(response.text, (text) => ({ status: response.status, text })),
|
|
81
|
+
),
|
|
82
|
+
// ⚠️ The CLI's default client timeout (api/client.go:324) — a hung agent must not hang a plan.
|
|
83
|
+
Effect.timeout('60 seconds'),
|
|
84
|
+
Effect.provideService(Headers.CurrentRedactedNames, [...redacted, 'x-vault-token']),
|
|
85
|
+
);
|
|
86
|
+
// ⚠️ `| undefined` under exactOptionalPropertyTypes: `socket` is narrowed to a string
|
|
87
|
+
// only on the branch below, so the object literal genuinely carries
|
|
88
|
+
// `unix: string | undefined` and the type must say so. The alternative — building
|
|
89
|
+
// the key conditionally — hides that this request is the unix-socket path.
|
|
90
|
+
const init: RequestInit & { unix?: string | undefined } = { ...defaults, unix: socket };
|
|
91
|
+
const { status, text } = yield* (
|
|
92
|
+
socket === undefined
|
|
93
|
+
? exchange
|
|
94
|
+
: Effect.provideService(exchange, FetchHttpClient.RequestInit, init)
|
|
95
|
+
).pipe(
|
|
96
|
+
// ⛔ `.message` only — the error object holds the request, and the request holds the token.
|
|
97
|
+
Effect.mapError((cause) => refuse(0, cause.message)),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
let parsed:
|
|
101
|
+
| {
|
|
102
|
+
lease_duration?: unknown;
|
|
103
|
+
data?: { token_id?: unknown; secret?: unknown };
|
|
104
|
+
errors?: unknown;
|
|
105
|
+
}
|
|
106
|
+
| undefined;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(text) as typeof parsed;
|
|
109
|
+
} catch {
|
|
110
|
+
parsed = undefined;
|
|
111
|
+
}
|
|
112
|
+
if (status < 200 || status >= 300) {
|
|
113
|
+
const errors = parsed?.errors;
|
|
114
|
+
const isOpenBaoBody = Array.isArray(errors);
|
|
115
|
+
const detail = isOpenBaoBody ? errors.map(String).join('; ') : 'not an OpenBao error body';
|
|
116
|
+
/**
|
|
117
|
+
* ⛔ 403 GETS THE TYPED TAG ONLY WITH A GENUINE OpenBao ERROR BODY — found on adversarial
|
|
118
|
+
* review 2026-09-24. Status alone is not enough: a WAF, a proxy in front of OpenBao, or an
|
|
119
|
+
* mTLS terminator can answer 403 without OpenBao ever seeing the request, and `parsed` is
|
|
120
|
+
* then `undefined`/`errors` is not an array (the `try`/`catch` above, or a body that
|
|
121
|
+
* parses but isn't OpenBao's shape). That is an infrastructure fault, not "this identity
|
|
122
|
+
* is denied this role" — tagging it `PveCredentialDenied` would fold a REAL outage into
|
|
123
|
+
* "unreadable, report noop" exactly as quietly as the bug this fix exists to remove. Only
|
|
124
|
+
* a parsed `errors` array is OpenBao's own documented error shape (mint.ts's own header),
|
|
125
|
+
* so it is the one signal trusted here.
|
|
126
|
+
*/
|
|
127
|
+
if (status === 403 && isOpenBaoBody) {
|
|
128
|
+
return yield* Effect.fail(new PveCredentialDenied({ detail, mount: target.mount, role }));
|
|
129
|
+
}
|
|
130
|
+
return yield* Effect.fail(refuse(status, detail || '(no errors given)'));
|
|
131
|
+
}
|
|
132
|
+
const tokenId = parsed?.data?.token_id;
|
|
133
|
+
const secret = parsed?.data?.secret;
|
|
134
|
+
if (typeof tokenId !== 'string' || typeof secret !== 'string') {
|
|
135
|
+
// ⛔ REFUSE RATHER THAN RETURN A HALF CREDENTIAL, and again WITHOUT quoting the body.
|
|
136
|
+
return yield* Effect.fail(
|
|
137
|
+
new Error(
|
|
138
|
+
`${target.mount}/creds/${role} returned no token_id/secret. Either this approle lacks ` +
|
|
139
|
+
`the grant, or the mount is not ${target.mount}. Say which mount and role you needed ` +
|
|
140
|
+
`than falling back to a stored credential -- every stored PVE token is read-only.`,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const lease = parsed?.lease_duration;
|
|
145
|
+
const leaseSeconds = typeof lease === 'number' ? lease : 0;
|
|
146
|
+
return { leaseSeconds, secret, tokenId } satisfies PveCredential;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The header PVE wants. `PVEAPIToken=<tokenid>=<secret>`.
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ THE SEPARATOR IS `=`, NOT `:`. Proxmox rejects the colon form with a 401 that says only
|
|
153
|
+
* "authentication failure", which reads as a bad credential rather than as a malformed header.
|
|
154
|
+
*/
|
|
155
|
+
export const authorization = (credential: PveCredential, target: ApiTarget): string =>
|
|
156
|
+
target.scheme === 'pbs'
|
|
157
|
+
? `PBSAPIToken=${credential.tokenId}:${credential.secret}`
|
|
158
|
+
: `PVEAPIToken=${credential.tokenId}=${credential.secret}`;
|
|
@@ -52,7 +52,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
52
52
|
import * as Effect from 'effect/Effect';
|
|
53
53
|
import { guardNetworkApply } from './apply-endpoints.ts';
|
|
54
54
|
import { pveWith } from './client.ts';
|
|
55
|
-
import { mint } from './
|
|
55
|
+
import { mint } from './mint.ts';
|
|
56
56
|
import { awaitTask, degradedReason, pendingCount } from './network-apply-read.ts';
|
|
57
57
|
import type { PveRequirements } from './resource.ts';
|
|
58
58
|
import type { WithTarget } from './resource.ts';
|
|
@@ -61,6 +61,17 @@ const text = (key: string, value: string) => (value === '' ? {} : { [key]: value
|
|
|
61
61
|
/** Every item GET the scripts and the providers make, keyed `access/…` without a leading slash. */
|
|
62
62
|
export const apiView = (state: CliState): Record<string, unknown> => {
|
|
63
63
|
const view: Record<string, unknown> = { 'access/acl': state.acl };
|
|
64
|
+
/**
|
|
65
|
+
* ★ THE LIST SHAPE, ADDED FOR role.ts's DISTILLED MIGRATION (2026-09-24). `Proxmox.Role` now
|
|
66
|
+
* reads `GET /access/roles` (role.ts's header: the item endpoint's generated schema has a
|
|
67
|
+
* fixed privilege enumeration this fixture must not rely on). The fake CLI's own `pvesh get
|
|
68
|
+
* /access/roles/{id}` calls (provision-bootstrap.ts's script) still use the ITEM entries
|
|
69
|
+
* below, so both stay populated from the same `state.roles`.
|
|
70
|
+
*/
|
|
71
|
+
view['access/roles'] = Object.entries(state.roles).map(([roleid, privs]) => ({
|
|
72
|
+
privs: privs.join(','),
|
|
73
|
+
roleid,
|
|
74
|
+
}));
|
|
64
75
|
for (const [id, privs] of Object.entries(state.roles)) {
|
|
65
76
|
view[`access/roles/${id}`] = Object.fromEntries(privs.map((priv) => [priv, 1]));
|
|
66
77
|
}
|
|
@@ -37,7 +37,12 @@ const bootstrapped = async (): Promise<CliState> => {
|
|
|
37
37
|
|
|
38
38
|
const engineOn = (state: CliState) => {
|
|
39
39
|
const live = apiView(state);
|
|
40
|
-
|
|
40
|
+
// ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}` label
|
|
41
|
+
// substitution (`hf-provision@pve` -> `hf-provision%40pve`), where `apiView`'s keys stay
|
|
42
|
+
// literal (it also serves the raw shell-CLI fake, which never sees a URL at all).
|
|
43
|
+
const fake = fakePve((call) =>
|
|
44
|
+
call.method === 'GET' ? live[decodeURIComponent(call.path)] : 'UPID:fake',
|
|
45
|
+
);
|
|
41
46
|
const providers = Layer.mergeAll(
|
|
42
47
|
ProxmoxAclProvider(),
|
|
43
48
|
ProxmoxGroupProvider(),
|