@homeflare/alchemy 0.31.0 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/proxmox/acl-wire.d.ts +26 -0
- package/dist/proxmox/acl-wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +28 -26
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +48 -89
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +2 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/client.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +27 -0
- package/dist/proxmox/credential-errors.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +0 -38
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/distilled-guard.d.ts +31 -0
- package/dist/proxmox/distilled-guard.d.ts.map +1 -0
- package/dist/proxmox/distilled-pve.d.ts +11 -4
- package/dist/proxmox/distilled-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts +68 -0
- package/dist/proxmox/group-wire.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +20 -24
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +730 -511
- package/dist/proxmox/index.js.map +40 -33
- package/dist/proxmox/lease-cache.d.ts +1 -1
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +50 -0
- package/dist/proxmox/mint.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +2 -2
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +4 -4
- package/dist/proxmox/role-wire.d.ts +28 -0
- package/dist/proxmox/role-wire.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +36 -52
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +50 -0
- package/dist/proxmox/unreadable-read.d.ts.map +1 -0
- package/dist/proxmox/user-wire.d.ts +47 -0
- package/dist/proxmox/user-wire.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +25 -32
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +2 -2
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- package/docs/upstream-conformance.md +14 -4
- package/package.json +2 -2
- package/src/grafana/index.ts +6 -5
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +36 -83
- package/src/proxmox/adopt-noop.test.ts +18 -0
- package/src/proxmox/api-token-adopt.test.ts +28 -12
- package/src/proxmox/api-token-form.ts +50 -93
- package/src/proxmox/api-token.ts +155 -133
- package/src/proxmox/client.ts +2 -7
- package/src/proxmox/constraints-forms.test.ts +7 -27
- package/src/proxmox/credential-errors.ts +33 -0
- package/src/proxmox/credentials.test.ts +29 -3
- package/src/proxmox/credentials.ts +4 -134
- package/src/proxmox/distilled-guard.ts +42 -0
- package/src/proxmox/distilled-pve.ts +15 -4
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -4
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/group-wire.ts +117 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +127 -109
- package/src/proxmox/lease-cache.ts +2 -1
- package/src/proxmox/lxc-lifecycle.ts +2 -1
- package/src/proxmox/mint.ts +158 -0
- package/src/proxmox/network-apply.ts +1 -1
- package/src/proxmox/provision-cli-fake.ts +11 -0
- package/src/proxmox/provision-declare.test.ts +6 -1
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +152 -104
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +148 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +136 -131
- package/src/verify/fake-engine.ts +23 -0
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,59 @@
|
|
|
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 —
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
36
|
+
* ⚠️ A MISSING USER IS A 500, NOT A 404 — MEASURED against the live cluster, "no such user
|
|
37
|
+
* ('x@pve')". distilled turns that into an `InternalServerError`, with no distinct "not found"
|
|
38
|
+
* tag to catch. `user-wire.ts`'s `readUser` (used by `read`/`reconcile`, where the create
|
|
39
|
+
* workflow needs "absent" from exactly this failure) still folds it; `readUserOrFail` (used by
|
|
40
|
+
* `diff` below) does not — see that function's own header for why folding it there was the
|
|
41
|
+
* cries-wolf bug all over again, just for a genuine PVE quirk instead of a permission denial.
|
|
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 { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
64
|
+
import {
|
|
65
|
+
USER_CREATE,
|
|
66
|
+
USER_UPDATE,
|
|
67
|
+
createForm,
|
|
68
|
+
dropUnreadable,
|
|
69
|
+
matches,
|
|
70
|
+
readUser,
|
|
71
|
+
readUserOrFail,
|
|
72
|
+
updateForm,
|
|
73
|
+
} from './user-wire.ts';
|
|
54
74
|
|
|
55
75
|
export interface UserProps extends WithTarget {
|
|
56
76
|
/**
|
|
@@ -103,115 +123,100 @@ export interface ProxmoxUser extends Resource<
|
|
|
103
123
|
|
|
104
124
|
export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
|
|
105
125
|
|
|
106
|
-
const str = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* ⚠️ MORE FORGIVING THAN `lxc.ts`'s `num`, ON PURPOSE. PVE's JSON is generated from a Perl schema
|
|
110
|
-
* and an integer field can arrive as `0` or as `"0"` depending on the release and the endpoint. A
|
|
111
|
-
* strict `typeof === 'number'` would silently fall back to 0 for the string form, so a declared
|
|
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
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* ⚠️ THE SHAPE DEPENDS ON WHICH ENDPOINT YOU ASKED. Reading ONE user gives an object keyed by token
|
|
136
|
-
* name; the collection read gives an array of records. This provider only ever reads the single
|
|
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
|
-
: [];
|
|
144
|
-
|
|
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
126
|
export const ProxmoxUserProvider = () =>
|
|
217
|
-
Provider.effect(
|
|
127
|
+
Provider.effect(
|
|
128
|
+
ProxmoxUser,
|
|
129
|
+
Effect.succeed(
|
|
130
|
+
ProxmoxUser.Provider.of({
|
|
131
|
+
/**
|
|
132
|
+
* ⛔ EMPTY, AND MORE POINTEDLY SO HERE THAN ANYWHERE ELSE. `GET /access/users` answers
|
|
133
|
+
* with every account on the cluster: `root@pam`, every human who logs in, every service
|
|
134
|
+
* identity someone made years ago. Adoption is an explicit act.
|
|
135
|
+
*/
|
|
136
|
+
list: () => Effect.succeed([]),
|
|
137
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
|
|
138
|
+
// of this very fix caught the gap. The engine calls this ONE hook from FOUR places
|
|
139
|
+
// (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
|
|
140
|
+
// cold-start adoption probe and interrupted-create recovery, and Apply.ts's
|
|
141
|
+
// delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
|
|
142
|
+
// `readUser`'s fold is still needed there or a brand-new account could never be adopted
|
|
143
|
+
// or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
|
|
144
|
+
// it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
|
|
145
|
+
// `diff`'s own situation below — a transient failure folded to `undefined` there is
|
|
146
|
+
// reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
|
|
147
|
+
// command over. `readUserOrFail` on that branch propagates it instead.
|
|
148
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
149
|
+
return dropUnreadable(
|
|
150
|
+
yield* output === undefined ? readUser(olds) : readUserOrFail(olds),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
153
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
154
|
+
if (!isResolved(news)) return undefined;
|
|
155
|
+
yield* guardWrite(USER_CREATE, createForm(news), output === undefined);
|
|
156
|
+
yield* guardWrite(USER_UPDATE, updateForm(news), false);
|
|
157
|
+
if (output === undefined) return undefined;
|
|
158
|
+
// ⚠️ `readUserOrFail`, NOT `readUser` — see that function's own header (user-wire.ts).
|
|
159
|
+
// A genuine failure here propagates and fails the whole plan loudly instead of
|
|
160
|
+
// folding to "absent" and forcing a false update, the cries-wolf class of bug.
|
|
161
|
+
const live = yield* readUserOrFail(news);
|
|
162
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
163
|
+
// `update` on an account that was plainly there — see unreadable-read.ts.
|
|
164
|
+
if (live === UNREADABLE) {
|
|
165
|
+
yield* unreadableWarning('Proxmox.User', news.userid);
|
|
166
|
+
return { action: 'noop' } as const;
|
|
167
|
+
}
|
|
168
|
+
if (live === undefined) {
|
|
169
|
+
yield* guardWrite(USER_CREATE, createForm(news), true);
|
|
170
|
+
return { action: 'update' } as const;
|
|
171
|
+
}
|
|
172
|
+
return matches(live, news)
|
|
173
|
+
? ({ action: 'noop' } as const)
|
|
174
|
+
: ({ action: 'update' } as const);
|
|
175
|
+
}),
|
|
176
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
177
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
178
|
+
// write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
|
|
179
|
+
// handles — and a wrongful create in that race fails loudly ("already exists")
|
|
180
|
+
// rather than silently duplicating, group.ts's reconcile has the same note.
|
|
181
|
+
const before = dropUnreadable(yield* readUser(news));
|
|
182
|
+
yield* guardWrite(USER_CREATE, createForm(news), before === undefined);
|
|
183
|
+
yield* guardWrite(USER_UPDATE, updateForm(news), false);
|
|
184
|
+
if (before === undefined) {
|
|
185
|
+
yield* runPve(
|
|
186
|
+
news.target,
|
|
187
|
+
'provision',
|
|
188
|
+
true,
|
|
189
|
+
access.createAccessUser(createForm(news)),
|
|
190
|
+
);
|
|
191
|
+
} else if (!matches(before, news)) {
|
|
192
|
+
yield* runPve(news.target, 'provision', true, access.putAccessUser(updateForm(news)));
|
|
193
|
+
}
|
|
194
|
+
const after = dropUnreadable(yield* readUser(news));
|
|
195
|
+
if (after === undefined) {
|
|
196
|
+
return yield* Effect.die(
|
|
197
|
+
new Error(
|
|
198
|
+
`access/users/${news.userid}: the write returned no error but the account is ` +
|
|
199
|
+
'still absent. PVE wraps every answer in {"data":...} and can report success ' +
|
|
200
|
+
'on a call that did nothing -- read back rather than trusting the status code.',
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return after;
|
|
205
|
+
}),
|
|
206
|
+
/**
|
|
207
|
+
* ⛔ DELETING A USER TAKES ITS API TOKENS AND ITS ACL ENTRIES WITH IT, in one call and
|
|
208
|
+
* without a confirmation. For the identity a credential mount vends from, that revokes
|
|
209
|
+
* every outstanding lease at once and every plan in this package stops working — this
|
|
210
|
+
* one included. PVE refuses only for `root@pam`; for everyone else it simply obeys.
|
|
211
|
+
*/
|
|
212
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
213
|
+
yield* runPve(
|
|
214
|
+
olds.target,
|
|
215
|
+
'provision',
|
|
216
|
+
true,
|
|
217
|
+
access.deleteAccessUser({ userid: olds.userid }),
|
|
218
|
+
);
|
|
219
|
+
}),
|
|
220
|
+
}),
|
|
221
|
+
),
|
|
222
|
+
);
|
|
@@ -125,11 +125,24 @@ export type Body = Effect.Effect<unknown, unknown, unknown>;
|
|
|
125
125
|
type Compiled = { readonly name: string; readonly stage: string; readonly services: never };
|
|
126
126
|
type PlanView = { readonly resources: Readonly<Record<string, { readonly action: string }>> };
|
|
127
127
|
|
|
128
|
+
/** One row of `Alchemy.Drift.detect`'s dry-run report — the fields `drift` below asserts on. */
|
|
129
|
+
export type DriftRow = { readonly action: string };
|
|
130
|
+
|
|
128
131
|
export interface FakeEngine {
|
|
129
132
|
/** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
|
|
130
133
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
131
134
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
132
135
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
136
|
+
/**
|
|
137
|
+
* `alchemy drift`'s own dry-run (`Alchemy.Drift.detect`) over whatever `deploy` already
|
|
138
|
+
* persisted — `body` is used only to resolve the stack's name/stage/providers, exactly as
|
|
139
|
+
* `verify` does; drift itself reads the state store, not `body`'s declarations. ⚠️ A DIFFERENT
|
|
140
|
+
* ENGINE PATH FROM `verify`'s `--all` READ, proxmox/read-failure-propagation.test.ts's own ⛔:
|
|
141
|
+
* `Drift.ts` calls a provider's `read` with `output` set to the PERSISTED attributes, and folds
|
|
142
|
+
* a `read` that resolves `undefined` into `{action: 'missing'}` with no error — silent, unlike
|
|
143
|
+
* `verify.ts`'s `readWithState`, which already wraps that same call in `Effect.catchCause`.
|
|
144
|
+
*/
|
|
145
|
+
readonly drift: (body: Body) => Promise<Record<string, DriftRow>>;
|
|
133
146
|
/**
|
|
134
147
|
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
135
148
|
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
@@ -152,6 +165,10 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
|
|
|
152
165
|
) => Effect.Effect<Compiled, unknown, never>;
|
|
153
166
|
const plan = Alchemy.Plan.make as unknown as (c: Compiled) => Effect.Effect<PlanView, unknown>;
|
|
154
167
|
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
168
|
+
const detectDrift = Alchemy.Drift.detect as unknown as (stack: {
|
|
169
|
+
name: string;
|
|
170
|
+
stage: string;
|
|
171
|
+
}) => Effect.Effect<{ resources: Record<string, DriftRow> }, unknown>;
|
|
155
172
|
|
|
156
173
|
const run = <A>(body: Body, next: (compiled: Compiled) => Effect.Effect<A, unknown, unknown>) =>
|
|
157
174
|
Effect.runPromise(
|
|
@@ -182,6 +199,12 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
|
|
|
182
199
|
run(body, (compiled) =>
|
|
183
200
|
verifySession({ context: compiled.services, stack: compiled as never }, options),
|
|
184
201
|
),
|
|
202
|
+
drift: (body) =>
|
|
203
|
+
run(body, (compiled) =>
|
|
204
|
+
detectDrift({ name: compiled.name, stage: compiled.stage }).pipe(
|
|
205
|
+
Effect.map((detected) => detected.resources),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
185
208
|
};
|
|
186
209
|
};
|
|
187
210
|
|