@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.
Files changed (133) hide show
  1. package/README.md +5 -4
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/proxmox/acl-wire.d.ts +26 -0
  5. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  6. package/dist/proxmox/acl.d.ts.map +1 -1
  7. package/dist/proxmox/api-token-form.d.ts +28 -26
  8. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  9. package/dist/proxmox/api-token.d.ts +48 -89
  10. package/dist/proxmox/api-token.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  12. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  13. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  14. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  15. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  16. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  17. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  18. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  19. package/dist/proxmox/client.d.ts +2 -2
  20. package/dist/proxmox/client.d.ts.map +1 -1
  21. package/dist/proxmox/credential-errors.d.ts +27 -0
  22. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  23. package/dist/proxmox/credentials.d.ts +0 -38
  24. package/dist/proxmox/credentials.d.ts.map +1 -1
  25. package/dist/proxmox/distilled-guard.d.ts +31 -0
  26. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  27. package/dist/proxmox/distilled-pve.d.ts +11 -4
  28. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  29. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  44. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  45. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  47. package/dist/proxmox/group-wire.d.ts +68 -0
  48. package/dist/proxmox/group-wire.d.ts.map +1 -0
  49. package/dist/proxmox/group.d.ts +20 -24
  50. package/dist/proxmox/group.d.ts.map +1 -1
  51. package/dist/proxmox/index.js +730 -511
  52. package/dist/proxmox/index.js.map +40 -33
  53. package/dist/proxmox/lease-cache.d.ts +1 -1
  54. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  55. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  56. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  57. package/dist/proxmox/mint.d.ts +50 -0
  58. package/dist/proxmox/mint.d.ts.map +1 -0
  59. package/dist/proxmox/network-apply-read.d.ts +2 -2
  60. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  61. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  62. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  63. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  64. package/dist/proxmox/resource.d.ts +4 -4
  65. package/dist/proxmox/role-wire.d.ts +28 -0
  66. package/dist/proxmox/role-wire.d.ts.map +1 -0
  67. package/dist/proxmox/role.d.ts +36 -52
  68. package/dist/proxmox/role.d.ts.map +1 -1
  69. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  70. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  71. package/dist/proxmox/unreadable-read.d.ts +50 -0
  72. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  73. package/dist/proxmox/user-wire.d.ts +47 -0
  74. package/dist/proxmox/user-wire.d.ts.map +1 -0
  75. package/dist/proxmox/user.d.ts +25 -32
  76. package/dist/proxmox/user.d.ts.map +1 -1
  77. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  78. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  79. package/dist/verify/fake-engine.d.ts +14 -0
  80. package/dist/verify/fake-engine.d.ts.map +1 -1
  81. package/docs/grafana.md +47 -17
  82. package/docs/upstream-conformance.md +14 -4
  83. package/package.json +2 -2
  84. package/src/grafana/index.ts +6 -5
  85. package/src/proxmox/acl-wire.ts +83 -0
  86. package/src/proxmox/acl.ts +36 -83
  87. package/src/proxmox/adopt-noop.test.ts +18 -0
  88. package/src/proxmox/api-token-adopt.test.ts +28 -12
  89. package/src/proxmox/api-token-form.ts +50 -93
  90. package/src/proxmox/api-token.ts +155 -133
  91. package/src/proxmox/client.ts +2 -7
  92. package/src/proxmox/constraints-forms.test.ts +7 -27
  93. package/src/proxmox/credential-errors.ts +33 -0
  94. package/src/proxmox/credentials.test.ts +29 -3
  95. package/src/proxmox/credentials.ts +4 -134
  96. package/src/proxmox/distilled-guard.ts +42 -0
  97. package/src/proxmox/distilled-pve.ts +15 -4
  98. package/src/proxmox/generated/constraints/index.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  100. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/group-wire.ts +117 -0
  116. package/src/proxmox/group.test.ts +110 -0
  117. package/src/proxmox/group.ts +127 -109
  118. package/src/proxmox/lease-cache.ts +2 -1
  119. package/src/proxmox/lxc-lifecycle.ts +2 -1
  120. package/src/proxmox/mint.ts +158 -0
  121. package/src/proxmox/network-apply.ts +1 -1
  122. package/src/proxmox/provision-cli-fake.ts +11 -0
  123. package/src/proxmox/provision-declare.test.ts +6 -1
  124. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  125. package/src/proxmox/role-wire.ts +49 -0
  126. package/src/proxmox/role.test.ts +89 -0
  127. package/src/proxmox/role.ts +152 -104
  128. package/src/proxmox/unreadable-read.test.ts +83 -0
  129. package/src/proxmox/unreadable-read.ts +60 -0
  130. package/src/proxmox/user-wire.ts +148 -0
  131. package/src/proxmox/user.test.ts +142 -0
  132. package/src/proxmox/user.ts +136 -131
  133. package/src/verify/fake-engine.ts +23 -0
@@ -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,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. 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 — 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 { 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 { 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(ProxmoxUser, Effect.succeed(ProxmoxUser.Provider.of(handlers)));
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