@homeflare/alchemy 0.31.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.
Files changed (123) hide show
  1. package/dist/proxmox/acl-wire.d.ts +26 -0
  2. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  3. package/dist/proxmox/acl.d.ts.map +1 -1
  4. package/dist/proxmox/api-token-form.d.ts +28 -26
  5. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  6. package/dist/proxmox/api-token.d.ts +48 -89
  7. package/dist/proxmox/api-token.d.ts.map +1 -1
  8. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  9. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  10. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  11. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  12. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  13. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  14. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  15. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  16. package/dist/proxmox/client.d.ts +2 -2
  17. package/dist/proxmox/client.d.ts.map +1 -1
  18. package/dist/proxmox/credential-errors.d.ts +27 -0
  19. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  20. package/dist/proxmox/credentials.d.ts +0 -38
  21. package/dist/proxmox/credentials.d.ts.map +1 -1
  22. package/dist/proxmox/distilled-guard.d.ts +31 -0
  23. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  24. package/dist/proxmox/distilled-pve.d.ts +11 -4
  25. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  26. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  27. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  28. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  29. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  44. package/dist/proxmox/group-wire.d.ts +29 -0
  45. package/dist/proxmox/group-wire.d.ts.map +1 -0
  46. package/dist/proxmox/group.d.ts +20 -24
  47. package/dist/proxmox/group.d.ts.map +1 -1
  48. package/dist/proxmox/index.js +725 -509
  49. package/dist/proxmox/index.js.map +40 -33
  50. package/dist/proxmox/lease-cache.d.ts +1 -1
  51. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  52. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  53. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  54. package/dist/proxmox/mint.d.ts +50 -0
  55. package/dist/proxmox/mint.d.ts.map +1 -0
  56. package/dist/proxmox/network-apply-read.d.ts +2 -2
  57. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  59. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  60. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  61. package/dist/proxmox/resource.d.ts +4 -4
  62. package/dist/proxmox/role-wire.d.ts +28 -0
  63. package/dist/proxmox/role-wire.d.ts.map +1 -0
  64. package/dist/proxmox/role.d.ts +36 -52
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  67. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  68. package/dist/proxmox/unreadable-read.d.ts +50 -0
  69. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  70. package/dist/proxmox/user-wire.d.ts +18 -0
  71. package/dist/proxmox/user-wire.d.ts.map +1 -0
  72. package/dist/proxmox/user.d.ts +25 -32
  73. package/dist/proxmox/user.d.ts.map +1 -1
  74. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  75. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  76. package/package.json +1 -1
  77. package/src/proxmox/acl-wire.ts +83 -0
  78. package/src/proxmox/acl.ts +29 -82
  79. package/src/proxmox/adopt-noop.test.ts +18 -0
  80. package/src/proxmox/api-token-adopt.test.ts +28 -12
  81. package/src/proxmox/api-token-form.ts +50 -93
  82. package/src/proxmox/api-token.ts +149 -135
  83. package/src/proxmox/client.ts +2 -7
  84. package/src/proxmox/constraints-forms.test.ts +7 -27
  85. package/src/proxmox/credential-errors.ts +33 -0
  86. package/src/proxmox/credentials.test.ts +29 -3
  87. package/src/proxmox/credentials.ts +4 -134
  88. package/src/proxmox/distilled-guard.ts +42 -0
  89. package/src/proxmox/distilled-pve.ts +15 -4
  90. package/src/proxmox/generated/constraints/index.ts +1 -1
  91. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  92. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  93. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  94. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  95. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  96. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  97. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  98. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  107. package/src/proxmox/group-wire.ts +63 -0
  108. package/src/proxmox/group.test.ts +110 -0
  109. package/src/proxmox/group.ts +135 -105
  110. package/src/proxmox/lease-cache.ts +2 -1
  111. package/src/proxmox/lxc-lifecycle.ts +2 -1
  112. package/src/proxmox/mint.ts +158 -0
  113. package/src/proxmox/network-apply.ts +1 -1
  114. package/src/proxmox/provision-cli-fake.ts +11 -0
  115. package/src/proxmox/provision-declare.test.ts +6 -1
  116. package/src/proxmox/role-wire.ts +49 -0
  117. package/src/proxmox/role.test.ts +89 -0
  118. package/src/proxmox/role.ts +147 -106
  119. package/src/proxmox/unreadable-read.test.ts +83 -0
  120. package/src/proxmox/unreadable-read.ts +60 -0
  121. package/src/proxmox/user-wire.ts +112 -0
  122. package/src/proxmox/user.test.ts +142 -0
  123. package/src/proxmox/user.ts +132 -129
@@ -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
+ });
@@ -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 `src/credentials.ts`
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. Compared naively, `matches` reads ['b','a'] against 'a,b' and every plan for
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}`. So they are reported as an attribute — a plan
29
- * can then say what a delete would take with it — and kept OUT of `matches`, exactly like
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 (pool.ts records how that went the last time).
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 in the
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')" — and the factory turns any
45
- * failed read into "absent". A 403 therefore also reads as absent, and the honest error arrives
46
- * one step later, from the create: "Permission check failed (/access/groups, User.Modify)". That
47
- * string is the one to grep for when a plan insists on creating an account that plainly exists.
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 { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
53
- import { bool } from './values.ts';
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
- 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
- };
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
- * ⚠️ 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
- : [];
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(ProxmoxUser, Effect.succeed(ProxmoxUser.Provider.of(handlers)));
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
+ );