@homeflare/alchemy 0.31.1 → 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.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +44 -5
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +232 -229
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +34 -5
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +6 -6
- package/dist/proxmox/user.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.ts +10 -4
- package/src/proxmox/api-token.ts +20 -12
- package/src/proxmox/group-wire.ts +62 -8
- package/src/proxmox/group.ts +23 -35
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role.ts +9 -2
- package/src/proxmox/user-wire.ts +44 -8
- package/src/proxmox/user.ts +29 -27
- package/src/verify/fake-engine.ts +23 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 0.31.1 regression (measured live 2026-09-24, homeflare-proxmox `bun run plan`): every
|
|
3
|
+
* `Proxmox.User`/`Proxmox.Group` row planned `update` with no warning, because their reads were
|
|
4
|
+
* silently folding EVERY failure — not only a refused credential — into "absent". Two fixes,
|
|
5
|
+
* both pinned here with REAL-SHAPED fixtures (live response bodies, nothing secret):
|
|
6
|
+
*
|
|
7
|
+
* 1. `access.getAccessUser`/`getAccessGroup` must be called with ONLY the schema's own label
|
|
8
|
+
* field, never the whole declared props object — the root cause: distilled's `buildRequest`
|
|
9
|
+
* treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
|
|
10
|
+
* a bodyless GET, which a stricter fetch client refuses outright.
|
|
11
|
+
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent" — only
|
|
12
|
+
* `read`/`reconcile` may, where a wrongful fold costs at most a redundant, loudly-refused
|
|
13
|
+
* create, never a silent wrong write.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
18
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
19
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
20
|
+
import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
21
|
+
import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A fake cluster whose GET on `path` answers 200 with `live`, but REFUSES (500, mimicking PVE's
|
|
25
|
+
* real "InternalServerError" shape) once `transientFrom` is `true` — and asserts no request ever
|
|
26
|
+
* carries a body on a GET, the request-shape regression's own signature.
|
|
27
|
+
*/
|
|
28
|
+
const clusterWithTransientFailure = (path: string, live: unknown) => {
|
|
29
|
+
let transient = false;
|
|
30
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
31
|
+
const request =
|
|
32
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
if (url.pathname.includes('/creds/')) {
|
|
35
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
36
|
+
return Response.json({ data, lease_duration: 0 });
|
|
37
|
+
}
|
|
38
|
+
if (request.method === 'GET') {
|
|
39
|
+
const body = await request.text();
|
|
40
|
+
// ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses this outright.
|
|
41
|
+
expect(body).toBe('');
|
|
42
|
+
}
|
|
43
|
+
// ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}`/
|
|
44
|
+
// `{groupid}` label substitution (`tim@pve` -> `tim%40pve`), which a literal `path` compare
|
|
45
|
+
// would silently never match, turning every case below into a no-op stub.
|
|
46
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
47
|
+
if (wire !== path) return Response.json({ data: null });
|
|
48
|
+
if (transient) {
|
|
49
|
+
// MEASURED 2026-09-24, live TB4: a missing user/group answers exactly this shape.
|
|
50
|
+
return Response.json({ data: null, message: `no such object (${path})` }, { status: 500 });
|
|
51
|
+
}
|
|
52
|
+
return Response.json({ data: live });
|
|
53
|
+
};
|
|
54
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
55
|
+
return {
|
|
56
|
+
breakReads: () => {
|
|
57
|
+
transient = true;
|
|
58
|
+
},
|
|
59
|
+
layer: FetchHttpClient.layer.pipe(
|
|
60
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('a transient read failure fails the plan loudly, never a silent false update', () => {
|
|
66
|
+
test('Proxmox.User: diff() propagates a transient read failure once a state row exists', async () => {
|
|
67
|
+
// MEASURED 2026-09-24, live TB4 `GET /access/users/tim@pve`.
|
|
68
|
+
const live = {
|
|
69
|
+
comment: 'Primary admin — PVE fallback',
|
|
70
|
+
email: 'tim@schenanigans.com',
|
|
71
|
+
enable: 1,
|
|
72
|
+
expire: 0,
|
|
73
|
+
firstname: 'Timothy',
|
|
74
|
+
groups: ['Schenanigans'],
|
|
75
|
+
lastname: 'Schneider',
|
|
76
|
+
tokens: null,
|
|
77
|
+
};
|
|
78
|
+
const declare = () =>
|
|
79
|
+
ProxmoxUser('tim', {
|
|
80
|
+
comment: 'Primary admin — PVE fallback',
|
|
81
|
+
email: 'tim@schenanigans.com',
|
|
82
|
+
firstname: 'Timothy',
|
|
83
|
+
groups: ['Schenanigans'],
|
|
84
|
+
lastname: 'Schneider',
|
|
85
|
+
target: FAKE_TARGET,
|
|
86
|
+
userid: 'tim@pve',
|
|
87
|
+
});
|
|
88
|
+
const fake = clusterWithTransientFailure('access/users/tim@pve', live);
|
|
89
|
+
await withoutBao(async () => {
|
|
90
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
91
|
+
// First deploy: adopts the matching account, records a state row.
|
|
92
|
+
expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
|
|
93
|
+
// Now every read of this account fails the way the live regression measured.
|
|
94
|
+
fake.breakReads();
|
|
95
|
+
// ⛔ THE FIX: this must REJECT (fail the plan), never resolve with a false 'update'.
|
|
96
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('Proxmox.Group: diff() propagates a transient read failure once a state row exists', async () => {
|
|
101
|
+
// MEASURED 2026-09-24, live TB4 `GET /access/groups/HomeAssistant`.
|
|
102
|
+
const live = { comment: 'Home Assistant service group', members: [] };
|
|
103
|
+
const declare = () =>
|
|
104
|
+
ProxmoxGroup('ha', {
|
|
105
|
+
comment: 'Home Assistant service group',
|
|
106
|
+
groupid: 'HomeAssistant',
|
|
107
|
+
target: FAKE_TARGET,
|
|
108
|
+
});
|
|
109
|
+
const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
|
|
110
|
+
await withoutBao(async () => {
|
|
111
|
+
const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
112
|
+
expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
|
|
113
|
+
fake.breakReads();
|
|
114
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ★ A brand-new declaration's own create-from-a-500 path is already covered end to end by
|
|
119
|
+
// user.test.ts's `missingAs500` fixture — `readUser`/`readGroup` (used by `reconcile`) kept
|
|
120
|
+
// their fold on purpose, exactly so that path stays unaffected by this fix.
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
|
|
124
|
+
// ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS VERY FIX, 2026-09-24 — NOT part of the live-measured
|
|
125
|
+
// regression above, but the SAME bug class one layer deeper. `alchemy/src/Drift.ts` (`alchemy
|
|
126
|
+
// drift`/`sync`/`deploy --detect-drift`) calls a provider's `read` hook DIRECTLY on an
|
|
127
|
+
// already-confirmed row (`output: old.attr`, defined — Drift.ts:239-292), the same situation
|
|
128
|
+
// `diff` handles above. Before `user.ts`/`group.ts`'s `read` hook learned to branch on
|
|
129
|
+
// `output` (this fix's second half), it ALWAYS called the folding `readUser`/`readGroup` —
|
|
130
|
+
// so a transient failure here folded to `undefined`, and Drift.ts's own dry-run branch
|
|
131
|
+
// reports that as `{action: 'missing'}` with NO error surfaced at all: a silent false "this
|
|
132
|
+
// account is gone" report, worse than the false `update` `diff` used to produce because
|
|
133
|
+
// nothing here even logs a warning. `engineOver`'s new `drift` capability
|
|
134
|
+
// (verify/fake-engine.ts) drives the real `Alchemy.Drift.detect` over the same fake cluster.
|
|
135
|
+
test('Proxmox.User: a confirmed row propagates instead of reporting missing', async () => {
|
|
136
|
+
const live = {
|
|
137
|
+
comment: 'Primary admin — PVE fallback',
|
|
138
|
+
email: 'tim@schenanigans.com',
|
|
139
|
+
enable: 1,
|
|
140
|
+
expire: 0,
|
|
141
|
+
firstname: 'Timothy',
|
|
142
|
+
groups: ['Schenanigans'],
|
|
143
|
+
lastname: 'Schneider',
|
|
144
|
+
tokens: null,
|
|
145
|
+
};
|
|
146
|
+
const declare = () =>
|
|
147
|
+
ProxmoxUser('tim', {
|
|
148
|
+
comment: 'Primary admin — PVE fallback',
|
|
149
|
+
email: 'tim@schenanigans.com',
|
|
150
|
+
firstname: 'Timothy',
|
|
151
|
+
groups: ['Schenanigans'],
|
|
152
|
+
lastname: 'Schneider',
|
|
153
|
+
target: FAKE_TARGET,
|
|
154
|
+
userid: 'tim@pve',
|
|
155
|
+
});
|
|
156
|
+
const fake = clusterWithTransientFailure('access/users/tim@pve', live);
|
|
157
|
+
await withoutBao(async () => {
|
|
158
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
159
|
+
expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
|
|
160
|
+
fake.breakReads();
|
|
161
|
+
// ⛔ THE FIX: this must REJECT. Before `read`'s `output` branch, it resolved with
|
|
162
|
+
// `{ tim: { action: 'missing' } }` and no error anywhere.
|
|
163
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('Proxmox.Group: a confirmed row propagates instead of reporting missing', async () => {
|
|
168
|
+
const live = { comment: 'Home Assistant service group', members: [] };
|
|
169
|
+
const declare = () =>
|
|
170
|
+
ProxmoxGroup('ha', {
|
|
171
|
+
comment: 'Home Assistant service group',
|
|
172
|
+
groupid: 'HomeAssistant',
|
|
173
|
+
target: FAKE_TARGET,
|
|
174
|
+
});
|
|
175
|
+
const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
|
|
176
|
+
await withoutBao(async () => {
|
|
177
|
+
const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
178
|
+
expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
|
|
179
|
+
fake.breakReads();
|
|
180
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/proxmox/role.ts
CHANGED
|
@@ -107,7 +107,15 @@ export interface ProxmoxRole extends Resource<
|
|
|
107
107
|
|
|
108
108
|
export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
|
|
109
109
|
|
|
110
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement.
|
|
112
|
+
* ⛔ NO `orElseSucceed` — found 2026-09-24, the same bug class as the credential denial fix. A
|
|
113
|
+
* role's absence is ALWAYS a SUCCESSFUL read (the list comes back and the role is not in it —
|
|
114
|
+
* `access.listAccessRoles` never 404s/500s per-role the way `getAccessUser`/`getAccessGroup`
|
|
115
|
+
* do, MEASURED), so this family never needed a folding/non-folding split the way user.ts and
|
|
116
|
+
* group.ts do: any THROWN failure here is genuinely unexpected and must propagate and fail the
|
|
117
|
+
* whole plan loudly, in `diff`, `read` and `reconcile` alike — never fold to "absent → update".
|
|
118
|
+
*/
|
|
111
119
|
const readRole = (props: RoleProps) =>
|
|
112
120
|
readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
|
|
113
121
|
Effect.map((rows) => {
|
|
@@ -115,7 +123,6 @@ const readRole = (props: RoleProps) =>
|
|
|
115
123
|
const row = find(rows, props.roleid);
|
|
116
124
|
return row === undefined ? undefined : attributesOf(row, props);
|
|
117
125
|
}),
|
|
118
|
-
Effect.orElseSucceed(() => undefined),
|
|
119
126
|
);
|
|
120
127
|
|
|
121
128
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
package/src/proxmox/user-wire.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.User`'s wire shape: PVE's create/update forms
|
|
3
|
-
*
|
|
4
|
-
* under the 250-line cap — the api-token.ts/api-token-form.ts seam.
|
|
2
|
+
* `Proxmox.User`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
|
|
3
|
+
* and the read itself. Split out of user.ts (2026-09-24, the distilled migration) to keep that
|
|
4
|
+
* file under the 250-line cap — the api-token.ts/api-token-form.ts seam.
|
|
5
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { runPve } from './distilled-pve.ts';
|
|
7
9
|
import type { UserAttributes, UserProps } from './user.ts';
|
|
10
|
+
import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
|
|
8
11
|
import { bool, text } from './values.ts';
|
|
9
12
|
|
|
10
13
|
export const USER_CREATE = 'pve:POST /access/users';
|
|
@@ -77,10 +80,7 @@ export const updateForm = (props: UserProps): access.PutAccessUserRequest => ({
|
|
|
77
80
|
userid: props.userid,
|
|
78
81
|
});
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
live: access.GetAccessUserResponse,
|
|
82
|
-
props: UserProps,
|
|
83
|
-
): UserAttributes => ({
|
|
83
|
+
const attributesOf = (live: access.GetAccessUserResponse, props: UserProps): UserAttributes => ({
|
|
84
84
|
comment: text(live.comment),
|
|
85
85
|
email: text(live.email),
|
|
86
86
|
/**
|
|
@@ -110,3 +110,39 @@ export const matches = (attributes: UserAttributes, props: UserProps) =>
|
|
|
110
110
|
attributes.firstname === (props.firstname ?? '') &&
|
|
111
111
|
attributes.lastname === (props.lastname ?? '') &&
|
|
112
112
|
attributes.groups.join(',') === groupSet(props.groups).join(',');
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* ★ Default `read` role for this family — only the write side needs `provision`.
|
|
116
|
+
* ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
|
|
117
|
+
* kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
|
|
118
|
+
* key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
|
|
119
|
+
* family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
|
|
120
|
+
* JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
|
|
121
|
+
* fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
|
|
122
|
+
* (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
|
|
123
|
+
* which `runPveWith` retries across every member and exhausts identically —
|
|
124
|
+
* `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
|
|
125
|
+
* false `update` on every account.
|
|
126
|
+
*/
|
|
127
|
+
export const readUserOrFail = (props: UserProps) =>
|
|
128
|
+
readOrUnreadable(
|
|
129
|
+
runPve(props.target, 'read', false, access.getAccessUser({ userid: props.userid })),
|
|
130
|
+
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
|
|
134
|
+
* missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
|
|
135
|
+
* "absent" from a failure to ever run — without this fold, a brand-new account could never be
|
|
136
|
+
* created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
|
|
137
|
+
* which PVE refuses loudly rather than silently corrupting anything.
|
|
138
|
+
* ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
|
|
139
|
+
* folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
|
|
140
|
+
* nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
|
|
141
|
+
* propagates and fails the whole plan loudly instead.
|
|
142
|
+
*/
|
|
143
|
+
export const readUser = (props: UserProps) =>
|
|
144
|
+
readUserOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
|
|
145
|
+
|
|
146
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
147
|
+
export const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
|
|
148
|
+
live === UNREADABLE ? undefined : live;
|
package/src/proxmox/user.ts
CHANGED
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
* user, so a role wide enough to edit the account can still be refused for one group.
|
|
34
34
|
* · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
|
|
35
35
|
*
|
|
36
|
-
* ⚠️ A MISSING USER IS A 500, NOT A 404 —
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
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
42
|
*
|
|
43
43
|
* ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
|
|
44
44
|
* (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —
|
|
@@ -60,18 +60,15 @@ import * as Effect from 'effect/Effect';
|
|
|
60
60
|
import { guardWrite } from './distilled-guard.ts';
|
|
61
61
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
62
62
|
import { runPve } from './distilled-pve.ts';
|
|
63
|
-
import {
|
|
64
|
-
UNREADABLE,
|
|
65
|
-
type Unreadable,
|
|
66
|
-
readOrUnreadable,
|
|
67
|
-
unreadableWarning,
|
|
68
|
-
} from './unreadable-read.ts';
|
|
63
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
69
64
|
import {
|
|
70
65
|
USER_CREATE,
|
|
71
66
|
USER_UPDATE,
|
|
72
|
-
attributesOf,
|
|
73
67
|
createForm,
|
|
68
|
+
dropUnreadable,
|
|
74
69
|
matches,
|
|
70
|
+
readUser,
|
|
71
|
+
readUserOrFail,
|
|
75
72
|
updateForm,
|
|
76
73
|
} from './user-wire.ts';
|
|
77
74
|
|
|
@@ -126,17 +123,6 @@ export interface ProxmoxUser extends Resource<
|
|
|
126
123
|
|
|
127
124
|
export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
|
|
128
125
|
|
|
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
|
-
);
|
|
135
|
-
|
|
136
|
-
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
137
|
-
const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
|
|
138
|
-
live === UNREADABLE ? undefined : live;
|
|
139
|
-
|
|
140
126
|
export const ProxmoxUserProvider = () =>
|
|
141
127
|
Provider.effect(
|
|
142
128
|
ProxmoxUser,
|
|
@@ -148,15 +134,31 @@ export const ProxmoxUserProvider = () =>
|
|
|
148
134
|
* identity someone made years ago. Adoption is an explicit act.
|
|
149
135
|
*/
|
|
150
136
|
list: () => Effect.succeed([]),
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
);
|
|
153
152
|
}),
|
|
154
153
|
diff: Effect.fn(function* ({ news, output }) {
|
|
155
154
|
if (!isResolved(news)) return undefined;
|
|
156
155
|
yield* guardWrite(USER_CREATE, createForm(news), output === undefined);
|
|
157
156
|
yield* guardWrite(USER_UPDATE, updateForm(news), false);
|
|
158
157
|
if (output === undefined) return undefined;
|
|
159
|
-
|
|
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);
|
|
160
162
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
161
163
|
// `update` on an account that was plainly there — see unreadable-read.ts.
|
|
162
164
|
if (live === UNREADABLE) {
|
|
@@ -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
|
|