@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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.User`'s wire shape: PVE's create/update forms and how a live item becomes
|
|
3
|
+
* attributes. Split out of user.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 { UserAttributes, UserProps } from './user.ts';
|
|
8
|
+
import { bool, text } from './values.ts';
|
|
9
|
+
|
|
10
|
+
export const USER_CREATE = 'pve:POST /access/users';
|
|
11
|
+
export const USER_UPDATE = 'pve:PUT /access/users/{userid}';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ⚠️ MORE FORGIVING THAN `lxc.ts`'s `num`, ON PURPOSE, AND DELIBERATELY NOT `values.ts`'s
|
|
15
|
+
* `int` (a different function of the same name, `Number.parseInt` on a STRING with a
|
|
16
|
+
* caller-given fallback). PVE's JSON is generated from a Perl schema and an integer field can
|
|
17
|
+
* arrive as `0` or `"0"` depending on the release and the endpoint; `Number(value ?? 0)`
|
|
18
|
+
* accepts either without a strict `typeof === 'number'` that would silently fall back for the
|
|
19
|
+
* string form and report an update `matches` can never settle.
|
|
20
|
+
*/
|
|
21
|
+
const int = (value: unknown) => {
|
|
22
|
+
const parsed = typeof value === 'number' ? value : Number(value ?? 0);
|
|
23
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An array (what the read returns), a comma string (what older releases and the write side use), or
|
|
28
|
+
* nothing — in; a sorted, deduplicated list out. Both sides of `matches` go through this, which is
|
|
29
|
+
* the only reason the diff ever settles.
|
|
30
|
+
*/
|
|
31
|
+
const groupSet = (value: unknown): string[] => {
|
|
32
|
+
const raw: unknown[] = Array.isArray(value)
|
|
33
|
+
? value
|
|
34
|
+
: typeof value === 'string'
|
|
35
|
+
? value.split(',')
|
|
36
|
+
: [];
|
|
37
|
+
const names = raw.map((group) => text(group).trim()).filter((group) => group !== '');
|
|
38
|
+
return [...new Set(names)].sort();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ⚠️ THE SHAPE DEPENDS ON WHICH ENDPOINT YOU ASKED. Reading ONE user gives an object keyed by
|
|
43
|
+
* token name; distilled types it `tokens?: unknown` for the same reason (the item and index
|
|
44
|
+
* responses disagree, and this provider only ever reads the item). Anything but that shape is
|
|
45
|
+
* reported as "no tokens" rather than guessed at.
|
|
46
|
+
*/
|
|
47
|
+
const tokenNames = (value: unknown): string[] =>
|
|
48
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
49
|
+
? Object.keys(value).sort()
|
|
50
|
+
: [];
|
|
51
|
+
|
|
52
|
+
/** Everything mutable, in the form PVE wants. Create adds `userid`; update sends exactly this. */
|
|
53
|
+
const shape = (props: UserProps) => ({
|
|
54
|
+
comment: props.comment ?? '',
|
|
55
|
+
email: props.email ?? '',
|
|
56
|
+
enable: props.enable === false ? '0' : '1',
|
|
57
|
+
expire: String(props.expire ?? 0),
|
|
58
|
+
firstname: props.firstname ?? '',
|
|
59
|
+
/**
|
|
60
|
+
* ⚠️ ALWAYS SENT, EVEN EMPTY. Omitting `groups` on a PUT leaves the existing memberships in
|
|
61
|
+
* place, so a group removed from the declaration would never actually be removed. Same for
|
|
62
|
+
* `email` and `comment`: the empty string is how PVE is told to clear a field.
|
|
63
|
+
* ⚠️ AND `append` IS DELIBERATELY NOT SET. Its default (0) REPLACES the list; setting it to 1
|
|
64
|
+
* would turn every reconcile into an add-only merge, which is the same non-settling diff.
|
|
65
|
+
*/
|
|
66
|
+
groups: groupSet(props.groups).join(','),
|
|
67
|
+
lastname: props.lastname ?? '',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const createForm = (props: UserProps): access.CreateAccessUserRequest => ({
|
|
71
|
+
...shape(props),
|
|
72
|
+
userid: props.userid,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const updateForm = (props: UserProps): access.PutAccessUserRequest => ({
|
|
76
|
+
...shape(props),
|
|
77
|
+
userid: props.userid,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const attributesOf = (
|
|
81
|
+
live: access.GetAccessUserResponse,
|
|
82
|
+
props: UserProps,
|
|
83
|
+
): UserAttributes => ({
|
|
84
|
+
comment: text(live.comment),
|
|
85
|
+
email: text(live.email),
|
|
86
|
+
/**
|
|
87
|
+
* ⚠️ ABSENT MEANS ENABLED. PVE's schema defaults `enable` to 1 and does not always write the
|
|
88
|
+
* key back for an enabled account. Reading absence as `false` would report an update on
|
|
89
|
+
* every plan and then DISABLE the account on the deploy that "fixed" it.
|
|
90
|
+
*/
|
|
91
|
+
enable: bool(live.enable, true),
|
|
92
|
+
expire: int(live.expire),
|
|
93
|
+
firstname: text(live.firstname),
|
|
94
|
+
groups: groupSet(live.groups),
|
|
95
|
+
lastname: text(live.lastname),
|
|
96
|
+
tokens: tokenNames(live.tokens),
|
|
97
|
+
/** ⚠️ FROM PROPS: `GET /access/users/{userid}` does not echo the userid back in its data. */
|
|
98
|
+
userid: props.userid,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
|
|
103
|
+
* what a PUT can put back; a field PVE reports but will not accept is drift nobody can fix.
|
|
104
|
+
*/
|
|
105
|
+
export const matches = (attributes: UserAttributes, props: UserProps) =>
|
|
106
|
+
attributes.comment === (props.comment ?? '') &&
|
|
107
|
+
attributes.email === (props.email ?? '') &&
|
|
108
|
+
attributes.enable === (props.enable !== false) &&
|
|
109
|
+
attributes.expire === (props.expire ?? 0) &&
|
|
110
|
+
attributes.firstname === (props.firstname ?? '') &&
|
|
111
|
+
attributes.lastname === (props.lastname ?? '') &&
|
|
112
|
+
attributes.groups.join(',') === groupSet(props.groups).join(',');
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.User` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
|
|
3
|
+
* `@distilled.cloud/proxmox` protocol — create, drift (including the `groups` array/comma-string
|
|
4
|
+
* asymmetry the header warns about), and delete. adopt-noop.test.ts (via Group) proves the
|
|
5
|
+
* shared "adopting a matching object only reads" property; this file is User's own write paths.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ A MISSING USER IS A REAL 500, NOT `fakePve`'s USUAL 200-with-null. user.ts's own header
|
|
8
|
+
* measures this, and it matters here: `GetAccessUserResponse` has NO required field, so a
|
|
9
|
+
* 200-with-`{}` (what `fakePve` always answers) decodes as a fully-present, all-default
|
|
10
|
+
* account rather than "absent" — the read would never fail and `create` would never trigger.
|
|
11
|
+
* `missingAs500` below is a small custom stub, not `fake-pve.ts`'s `fakePve`, for that reason.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Layer from 'effect/Layer';
|
|
16
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
17
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
18
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { ProxmoxUser, ProxmoxUserProvider, type UserProps } from './user.ts';
|
|
20
|
+
|
|
21
|
+
type Live = { comment: string; enable: string; expire: string; groups: readonly string[] };
|
|
22
|
+
|
|
23
|
+
/** `access/users` (POST) and `access/users/{userid}` (GET/PUT), a missing GET answering 500. */
|
|
24
|
+
const missingAs500 = (users: Map<string, Live>) => {
|
|
25
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
26
|
+
const request =
|
|
27
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
if (url.pathname.includes('/creds/')) {
|
|
30
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
31
|
+
return Response.json({ data, lease_duration: 0 });
|
|
32
|
+
}
|
|
33
|
+
const path = url.pathname.replace(/^\/api2\/json\//, '');
|
|
34
|
+
const match = /^access\/users(?:\/(.+))?$/.exec(path);
|
|
35
|
+
if (match === null) return Response.json({ data: null });
|
|
36
|
+
// ⚠️ distilled percent-encodes a `{userid}` label substitution (`iac@pve` -> `iac%40pve`,
|
|
37
|
+
// MEASURED) where client.ts's plain string concatenation never did. A real PVE decodes it
|
|
38
|
+
// like any HTTP server; this fake must too, or every userid with an `@` "vanishes".
|
|
39
|
+
const userid = match[1] === undefined ? undefined : decodeURIComponent(match[1]);
|
|
40
|
+
const params = new URLSearchParams(await request.text());
|
|
41
|
+
const form = Object.fromEntries(params);
|
|
42
|
+
if (request.method === 'GET' && userid !== undefined) {
|
|
43
|
+
const live = users.get(userid);
|
|
44
|
+
// ⚠️ THE TRAP THIS FILE EXISTS TO PROVE FIXED: measured PVE answers 500, not 200-null.
|
|
45
|
+
if (live === undefined)
|
|
46
|
+
return Response.json({ message: `no such user ('${userid}')` }, { status: 500 });
|
|
47
|
+
return Response.json({ data: { ...live, groups: [...live.groups] } });
|
|
48
|
+
}
|
|
49
|
+
if (request.method === 'POST' && userid === undefined) {
|
|
50
|
+
const { userid: id, ...rest } = form;
|
|
51
|
+
if (id !== undefined) {
|
|
52
|
+
users.set(id, {
|
|
53
|
+
comment: rest['comment'] ?? '',
|
|
54
|
+
enable: rest['enable'] ?? '1',
|
|
55
|
+
expire: rest['expire'] ?? '0',
|
|
56
|
+
groups:
|
|
57
|
+
rest['groups'] === undefined || rest['groups'] === '' ? [] : rest['groups'].split(','),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return Response.json({ data: null });
|
|
61
|
+
}
|
|
62
|
+
if (request.method === 'PUT' && userid !== undefined) {
|
|
63
|
+
const live = users.get(userid);
|
|
64
|
+
if (live !== undefined) {
|
|
65
|
+
users.set(userid, {
|
|
66
|
+
comment: form['comment'] ?? live.comment,
|
|
67
|
+
enable: form['enable'] ?? live.enable,
|
|
68
|
+
expire: form['expire'] ?? live.expire,
|
|
69
|
+
groups:
|
|
70
|
+
form['groups'] === undefined || form['groups'] === '' ? [] : form['groups'].split(','),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return Response.json({ data: null });
|
|
74
|
+
}
|
|
75
|
+
if (request.method === 'DELETE' && userid !== undefined) {
|
|
76
|
+
users.delete(userid);
|
|
77
|
+
return Response.json({ data: null });
|
|
78
|
+
}
|
|
79
|
+
return Response.json({ data: null });
|
|
80
|
+
};
|
|
81
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
82
|
+
return {
|
|
83
|
+
layer: FetchHttpClient.layer.pipe(
|
|
84
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const declare = (over: Partial<UserProps> = {}): UserProps => ({
|
|
90
|
+
target: FAKE_TARGET,
|
|
91
|
+
userid: 'iac@pve',
|
|
92
|
+
...over,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Proxmox.User over the distilled protocol', () => {
|
|
96
|
+
test('creating an account that does not exist yet: one POST, defaults sent explicitly', async () => {
|
|
97
|
+
const users = new Map<string, Live>();
|
|
98
|
+
const fake = missingAs500(users);
|
|
99
|
+
await withoutBao(async () => {
|
|
100
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
101
|
+
expect(await engine.deploy(ProxmoxUser('iac', declare()))).toEqual({ iac: 'create' });
|
|
102
|
+
});
|
|
103
|
+
expect(users.get('iac@pve')).toEqual({ comment: '', enable: '1', expire: '0', groups: [] });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a drifted comment settles, and an unsorted live groups array is not itself drift', async () => {
|
|
107
|
+
const users = new Map<string, Live>([
|
|
108
|
+
['iac@pve', { comment: 'old', enable: '1', expire: '0', groups: ['b', 'a'] }],
|
|
109
|
+
]);
|
|
110
|
+
const fake = missingAs500(users);
|
|
111
|
+
await withoutBao(async () => {
|
|
112
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
113
|
+
const declared = () => ProxmoxUser('iac', declare({ comment: 'new', groups: ['a', 'b'] }));
|
|
114
|
+
const report = await engine.verify(declared());
|
|
115
|
+
expect(report.rows[0]).toMatchObject({ changed: ['comment'], diff: 'update' });
|
|
116
|
+
expect(await engine.deploy(declared())).toEqual({ iac: 'adopted' });
|
|
117
|
+
});
|
|
118
|
+
expect(users.get('iac@pve')).toMatchObject({ comment: 'new', groups: ['a', 'b'] });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('deleting removes the account (no retain default for User)', async () => {
|
|
122
|
+
const users = new Map<string, Live>([
|
|
123
|
+
['iac@pve', { comment: '', enable: '1', expire: '0', groups: [] }],
|
|
124
|
+
]);
|
|
125
|
+
const fake = fakePve((call: PveCall) => {
|
|
126
|
+
const match = /^access\/users\/(.+)$/.exec(call.path);
|
|
127
|
+
if (match === null) return undefined;
|
|
128
|
+
// ⚠️ decodeURIComponent — see the ⚠️ on `missingAs500` above: distilled percent-encodes it.
|
|
129
|
+
const userid = decodeURIComponent(match[1] as string);
|
|
130
|
+
if (call.method === 'GET') return users.get(userid);
|
|
131
|
+
if (call.method === 'DELETE') users.delete(userid);
|
|
132
|
+
return undefined;
|
|
133
|
+
});
|
|
134
|
+
await withoutBao(async () => {
|
|
135
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
136
|
+
await engine.deploy(ProxmoxUser('iac', declare()));
|
|
137
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
138
|
+
});
|
|
139
|
+
expect(users.has('iac@pve')).toBe(false);
|
|
140
|
+
expect(fake.writes()).toEqual(['DELETE access/users/iac%40pve']);
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/proxmox/user.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `Proxmox.User` — a PVE account. The identity every other resource in this package borrows.
|
|
3
3
|
*
|
|
4
4
|
* ★ THIS IS THE ONE OBJECT THAT CAN CUT THE BRANCH IT SITS ON. The user the credential mount vends
|
|
5
|
-
* tokens for, and the group whose members are allowed to mint, are what `
|
|
5
|
+
* tokens for, and the group whose members are allowed to mint, are what `credentials.ts`
|
|
6
6
|
* ultimately asks OpenBao for. A cluster rebuilt without them cannot plan ANY resource here —
|
|
7
7
|
* including the plan that would recreate them, which needs a credential to run. So this resource
|
|
8
8
|
* is drift repair for an account that already exists (comment, email, enable, expire, groups),
|
|
@@ -18,39 +18,62 @@
|
|
|
18
18
|
*
|
|
19
19
|
* ⚠️ `groups` COMES BACK AS AN ARRAY AND GOES OUT AS A COMMA STRING, and the asymmetry is the whole
|
|
20
20
|
* trap. GET answers `["a","b"]`, POST/PUT want `a,b`, and PVE returns the list in ITS order, not
|
|
21
|
-
* the declared one.
|
|
22
|
-
* the rest of time says "1 to update". Both sides are normalised to a sorted, deduplicated set
|
|
23
|
-
* below. Joining is a safe comparison precisely because a group id cannot contain a comma — the
|
|
24
|
-
* wire format is comma-separated, so PVE refuses one.
|
|
21
|
+
* the declared one. Both sides are normalised to a sorted, deduplicated set — see user-wire.ts.
|
|
25
22
|
*
|
|
26
23
|
* ⚠️ `tokens` IS NESTED AND IS NOT SETTABLE THROUGH THIS ENDPOINT. The read hands back a map of the
|
|
27
24
|
* account's API tokens; `PUT /access/users/{userid}` has no parameter for them, because they are
|
|
28
|
-
* their own objects under `.../token/{tokenid}
|
|
29
|
-
*
|
|
30
|
-
* `pool.members`. Reported, never declared.
|
|
25
|
+
* their own objects under `.../token/{tokenid}` (api-token.ts). So they are reported as an
|
|
26
|
+
* attribute and kept OUT of `matches`, exactly like `pool.members`. Reported, never declared.
|
|
31
27
|
*
|
|
32
28
|
* ⚠️ RECONCILE NEEDS PRIVILEGES A GUEST-ONLY ROLE DOES NOT HOLD, so they are stated rather than met
|
|
33
|
-
* as a 403 in the middle of a deploy (
|
|
34
|
-
* `PROVISION_PRIVILEGES` (provision-baseline.ts) carries every one of them:
|
|
29
|
+
* as a 403 in the middle of a deploy. `PROVISION_PRIVILEGES` (provision-baseline.ts) carries:
|
|
35
30
|
* · `Realm.AllocateUser` on `/access/realm/<realm>` — create and delete.
|
|
36
31
|
* · `User.Modify` on `/access/groups`, and on `/access/groups/<group>` for EVERY group named in
|
|
37
32
|
* `groups` — create, update and delete. PVE checks the groups you are granting, not just the
|
|
38
|
-
* user, so a role wide enough to edit the account can still be refused for one group
|
|
39
|
-
* list.
|
|
33
|
+
* user, so a role wide enough to edit the account can still be refused for one group.
|
|
40
34
|
* · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
|
|
41
|
-
* `Permissions.Modify` is NOT on this list: that governs ACL entries, which are a different PVE
|
|
42
|
-
* object and would be a different resource here.
|
|
43
35
|
*
|
|
44
|
-
* ⚠️ A MISSING USER IS A 500, NOT A 404 — "no such user ('x@pve')"
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
36
|
+
* ⚠️ A MISSING USER IS A 500, NOT A 404 — "no such user ('x@pve')". distilled turns that into an
|
|
37
|
+
* `UnknownProxmoxError` (protocol.ts), and `readOrUnreadable`'s own `orElseSucceed` folds it
|
|
38
|
+
* into "absent" same as the old hand-rolled client did. A 403 therefore also reads as absent
|
|
39
|
+
* UNLESS it is the one refusal `readOrUnreadable` catches (unreadable-read.ts): a REFUSED
|
|
40
|
+
* `provision` mint, the cries-wolf case. Any other refusal still reads as absent, and the
|
|
41
|
+
* honest error arrives one step later, from the create.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
|
|
44
|
+
* (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —
|
|
45
|
+
* `iac@pve` -> `.../access/users/iac%40pve` — where `client.ts`'s plain string concatenation
|
|
46
|
+
* sent the `@` literally. A real PVE (any HTTP server) decodes both the same way, so this is
|
|
47
|
+
* not a behaviour change a stack observes; a fake in a test has to decode it, `client.ts`'s
|
|
48
|
+
* own fixtures never had to.
|
|
49
|
+
*
|
|
50
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` ONTO `@distilled.cloud/proxmox`'s
|
|
51
|
+
* typed `access.getAccessUser`/`createAccessUser`/`putAccessUser`/`deleteAccessUser`
|
|
52
|
+
* (2026-09-24, decision 43's proxmox walk-down, access sub-area). `distilled-pve.ts`'s `runPve`
|
|
53
|
+
* replaces `pve()` as in acl.ts and group.ts; the cries-wolf fix is wired the same way.
|
|
48
54
|
*/
|
|
49
55
|
import { Resource } from 'alchemy';
|
|
56
|
+
import { isResolved } from 'alchemy/Diff';
|
|
50
57
|
import * as Provider from 'alchemy/Provider';
|
|
58
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
51
59
|
import * as Effect from 'effect/Effect';
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
60
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
61
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
62
|
+
import { runPve } from './distilled-pve.ts';
|
|
63
|
+
import {
|
|
64
|
+
UNREADABLE,
|
|
65
|
+
type Unreadable,
|
|
66
|
+
readOrUnreadable,
|
|
67
|
+
unreadableWarning,
|
|
68
|
+
} from './unreadable-read.ts';
|
|
69
|
+
import {
|
|
70
|
+
USER_CREATE,
|
|
71
|
+
USER_UPDATE,
|
|
72
|
+
attributesOf,
|
|
73
|
+
createForm,
|
|
74
|
+
matches,
|
|
75
|
+
updateForm,
|
|
76
|
+
} from './user-wire.ts';
|
|
54
77
|
|
|
55
78
|
export interface UserProps extends WithTarget {
|
|
56
79
|
/**
|
|
@@ -103,115 +126,95 @@ export interface ProxmoxUser extends Resource<
|
|
|
103
126
|
|
|
104
127
|
export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
|
|
105
128
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
* `expire` would never equal the live one and the plan would report an update it cannot settle.
|
|
113
|
-
*/
|
|
114
|
-
const int = (value: unknown) => {
|
|
115
|
-
const parsed = typeof value === 'number' ? value : Number(value ?? 0);
|
|
116
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* An array (what the read returns), a comma string (what older releases and the write side use), or
|
|
121
|
-
* nothing — in; a sorted, deduplicated list out. Both sides of `matches` go through this, which is
|
|
122
|
-
* the only reason the diff ever settles.
|
|
123
|
-
*/
|
|
124
|
-
const groupSet = (value: unknown): string[] => {
|
|
125
|
-
const raw: unknown[] = Array.isArray(value)
|
|
126
|
-
? value
|
|
127
|
-
: typeof value === 'string'
|
|
128
|
-
? value.split(',')
|
|
129
|
-
: [];
|
|
130
|
-
const names = raw.map((group) => str(group).trim()).filter((group) => group !== '');
|
|
131
|
-
return [...new Set(names)].sort();
|
|
132
|
-
};
|
|
129
|
+
/** ★ Default `read` role for this family — only the write side needs `provision`. */
|
|
130
|
+
const readUser = (props: UserProps) =>
|
|
131
|
+
readOrUnreadable(runPve(props.target, 'read', false, access.getAccessUser(props))).pipe(
|
|
132
|
+
Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
|
|
133
|
+
Effect.orElseSucceed(() => undefined),
|
|
134
|
+
);
|
|
133
135
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* user path, so the map is what arrives — anything else is reported as "no tokens" rather than
|
|
138
|
-
* guessed at, because a wrong guess here would show up as a phantom revocation in a plan.
|
|
139
|
-
*/
|
|
140
|
-
const tokenNames = (value: unknown): string[] =>
|
|
141
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
142
|
-
? Object.keys(value).sort()
|
|
143
|
-
: [];
|
|
136
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
137
|
+
const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
|
|
138
|
+
live === UNREADABLE ? undefined : live;
|
|
144
139
|
|
|
145
|
-
/** Everything mutable, in the form PVE wants. Create adds `userid`; update sends exactly this. */
|
|
146
|
-
const shape = (props: UserProps) => ({
|
|
147
|
-
comment: props.comment ?? '',
|
|
148
|
-
email: props.email ?? '',
|
|
149
|
-
enable: props.enable === false ? '0' : '1',
|
|
150
|
-
expire: String(props.expire ?? 0),
|
|
151
|
-
firstname: props.firstname ?? '',
|
|
152
|
-
/**
|
|
153
|
-
* ⚠️ ALWAYS SENT, EVEN EMPTY. Omitting `groups` on a PUT leaves the existing memberships in
|
|
154
|
-
* place, so a group removed from the declaration would never actually be removed — and
|
|
155
|
-
* `matches` would keep reporting an update that the update cannot fix. Same for `email` and
|
|
156
|
-
* `comment`: the empty string is how PVE is told to clear a field.
|
|
157
|
-
* ⚠️ AND `append` IS DELIBERATELY NOT SET. Its default (0) REPLACES the list; setting it to 1
|
|
158
|
-
* would turn every reconcile into an add-only merge, which is the same non-settling diff.
|
|
159
|
-
*/
|
|
160
|
-
groups: groupSet(props.groups).join(','),
|
|
161
|
-
lastname: props.lastname ?? '',
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
const handlers = pveHandlers<UserProps, UserAttributes>({
|
|
165
|
-
attributes: (live, props) => ({
|
|
166
|
-
comment: str(live['comment']),
|
|
167
|
-
email: str(live['email']),
|
|
168
|
-
/**
|
|
169
|
-
* ⚠️ ABSENT MEANS ENABLED. PVE's schema defaults `enable` to 1 and does not always write the
|
|
170
|
-
* key back for an enabled account. Reading absence as `false` would report an update on every
|
|
171
|
-
* plan and then DISABLE the account on the deploy that "fixed" it.
|
|
172
|
-
*/
|
|
173
|
-
enable: bool(live['enable'], true),
|
|
174
|
-
expire: int(live['expire']),
|
|
175
|
-
firstname: str(live['firstname']),
|
|
176
|
-
groups: groupSet(live['groups']),
|
|
177
|
-
lastname: str(live['lastname']),
|
|
178
|
-
tokens: tokenNames(live['tokens']),
|
|
179
|
-
/** ⚠️ FROM PROPS: `GET /access/users/{userid}` does not echo the userid back in its data. */
|
|
180
|
-
userid: props.userid,
|
|
181
|
-
}),
|
|
182
|
-
collection: () => 'access/users',
|
|
183
|
-
createForm: (props) => ({ ...shape(props), userid: props.userid }),
|
|
184
|
-
/**
|
|
185
|
-
* ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
|
|
186
|
-
* what a PUT can put back; a field that PVE reports but will not accept is drift nobody can
|
|
187
|
-
* fix, and diffing it would report an update on every plan forever.
|
|
188
|
-
*/
|
|
189
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
190
|
-
endpoint: { create: 'pve:POST /access/users', update: 'pve:PUT /access/users/{userid}' },
|
|
191
|
-
matches: (attributes, props) =>
|
|
192
|
-
attributes.comment === (props.comment ?? '') &&
|
|
193
|
-
attributes.email === (props.email ?? '') &&
|
|
194
|
-
attributes.enable === (props.enable !== false) &&
|
|
195
|
-
attributes.expire === (props.expire ?? 0) &&
|
|
196
|
-
attributes.firstname === (props.firstname ?? '') &&
|
|
197
|
-
attributes.lastname === (props.lastname ?? '') &&
|
|
198
|
-
attributes.groups.join(',') === groupSet(props.groups).join(','),
|
|
199
|
-
path: (props) => `access/users/${props.userid}`,
|
|
200
|
-
updateForm: shape,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* ⛔ EMPTY, AND MORE POINTEDLY SO HERE THAN ANYWHERE ELSE. `GET /access/users` answers with
|
|
205
|
-
* every account on the cluster: `root@pam`, every human who logs in, every service
|
|
206
|
-
* identity someone made years ago. Handing that list to Alchemy would invite it to adopt
|
|
207
|
-
* — and therefore one day DELETE — people. Adoption is an explicit act.
|
|
208
|
-
|
|
209
|
-
*
|
|
210
|
-
* ⛔ DELETING A USER TAKES ITS API TOKENS AND ITS ACL ENTRIES WITH IT, in one call and
|
|
211
|
-
* without a confirmation. For the identity a credential mount vends from, that revokes
|
|
212
|
-
* every outstanding lease at once and every plan in this package stops working — this one
|
|
213
|
-
* included. PVE refuses only for `root@pam`; for everyone else it simply obeys.
|
|
214
|
-
|
|
215
|
-
*/
|
|
216
140
|
export const ProxmoxUserProvider = () =>
|
|
217
|
-
Provider.effect(
|
|
141
|
+
Provider.effect(
|
|
142
|
+
ProxmoxUser,
|
|
143
|
+
Effect.succeed(
|
|
144
|
+
ProxmoxUser.Provider.of({
|
|
145
|
+
/**
|
|
146
|
+
* ⛔ EMPTY, AND MORE POINTEDLY SO HERE THAN ANYWHERE ELSE. `GET /access/users` answers
|
|
147
|
+
* with every account on the cluster: `root@pam`, every human who logs in, every service
|
|
148
|
+
* identity someone made years ago. Adoption is an explicit act.
|
|
149
|
+
*/
|
|
150
|
+
list: () => Effect.succeed([]),
|
|
151
|
+
read: Effect.fn(function* ({ olds }) {
|
|
152
|
+
return dropUnreadable(yield* readUser(olds));
|
|
153
|
+
}),
|
|
154
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
155
|
+
if (!isResolved(news)) return undefined;
|
|
156
|
+
yield* guardWrite(USER_CREATE, createForm(news), output === undefined);
|
|
157
|
+
yield* guardWrite(USER_UPDATE, updateForm(news), false);
|
|
158
|
+
if (output === undefined) return undefined;
|
|
159
|
+
const live = yield* readUser(news);
|
|
160
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
161
|
+
// `update` on an account that was plainly there — see unreadable-read.ts.
|
|
162
|
+
if (live === UNREADABLE) {
|
|
163
|
+
yield* unreadableWarning('Proxmox.User', news.userid);
|
|
164
|
+
return { action: 'noop' } as const;
|
|
165
|
+
}
|
|
166
|
+
if (live === undefined) {
|
|
167
|
+
yield* guardWrite(USER_CREATE, createForm(news), true);
|
|
168
|
+
return { action: 'update' } as const;
|
|
169
|
+
}
|
|
170
|
+
return matches(live, news)
|
|
171
|
+
? ({ action: 'noop' } as const)
|
|
172
|
+
: ({ action: 'update' } as const);
|
|
173
|
+
}),
|
|
174
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
175
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
176
|
+
// write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
|
|
177
|
+
// handles — and a wrongful create in that race fails loudly ("already exists")
|
|
178
|
+
// rather than silently duplicating, group.ts's reconcile has the same note.
|
|
179
|
+
const before = dropUnreadable(yield* readUser(news));
|
|
180
|
+
yield* guardWrite(USER_CREATE, createForm(news), before === undefined);
|
|
181
|
+
yield* guardWrite(USER_UPDATE, updateForm(news), false);
|
|
182
|
+
if (before === undefined) {
|
|
183
|
+
yield* runPve(
|
|
184
|
+
news.target,
|
|
185
|
+
'provision',
|
|
186
|
+
true,
|
|
187
|
+
access.createAccessUser(createForm(news)),
|
|
188
|
+
);
|
|
189
|
+
} else if (!matches(before, news)) {
|
|
190
|
+
yield* runPve(news.target, 'provision', true, access.putAccessUser(updateForm(news)));
|
|
191
|
+
}
|
|
192
|
+
const after = dropUnreadable(yield* readUser(news));
|
|
193
|
+
if (after === undefined) {
|
|
194
|
+
return yield* Effect.die(
|
|
195
|
+
new Error(
|
|
196
|
+
`access/users/${news.userid}: the write returned no error but the account is ` +
|
|
197
|
+
'still absent. PVE wraps every answer in {"data":...} and can report success ' +
|
|
198
|
+
'on a call that did nothing -- read back rather than trusting the status code.',
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return after;
|
|
203
|
+
}),
|
|
204
|
+
/**
|
|
205
|
+
* ⛔ DELETING A USER TAKES ITS API TOKENS AND ITS ACL ENTRIES WITH IT, in one call and
|
|
206
|
+
* without a confirmation. For the identity a credential mount vends from, that revokes
|
|
207
|
+
* every outstanding lease at once and every plan in this package stops working — this
|
|
208
|
+
* one included. PVE refuses only for `root@pam`; for everyone else it simply obeys.
|
|
209
|
+
*/
|
|
210
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
211
|
+
yield* runPve(
|
|
212
|
+
olds.target,
|
|
213
|
+
'provision',
|
|
214
|
+
true,
|
|
215
|
+
access.deleteAccessUser({ userid: olds.userid }),
|
|
216
|
+
);
|
|
217
|
+
}),
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
);
|