@homeflare/alchemy 0.37.0 → 0.37.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/dist/proxmox/ceph-daemon-wire.d.ts +5 -7
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon.d.ts +7 -8
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +31 -5
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +5 -6
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +9 -8
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +11 -20
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +8 -13
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +296 -326
- package/dist/proxmox/index.js.map +14 -14
- package/dist/proxmox/node-network-wire.d.ts +1 -1
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/dist/proxmox/storage-wire.d.ts +8 -11
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +7 -7
- package/dist/proxmox/user-wire.d.ts +8 -11
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +5 -5
- package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
- package/docs/changelog/README.md +22 -0
- package/docs/changelog/archive-01.md +176 -0
- package/docs/changelog/archive-02.md +180 -0
- package/docs/changelog/archive-03.md +179 -0
- package/docs/changelog/archive-04.md +178 -0
- package/docs/changelog/archive-05.md +177 -0
- package/docs/changelog/archive-06.md +180 -0
- package/docs/changelog/archive-07.md +180 -0
- package/docs/changelog/archive-08.md +178 -0
- package/docs/changelog/archive-09.md +181 -0
- package/docs/changelog/archive-10.md +182 -0
- package/docs/changelog/archive-11.md +182 -0
- package/docs/changelog/archive-12.md +183 -0
- package/docs/changelog/archive-13.md +180 -0
- package/docs/changelog/archive-14.md +178 -0
- package/docs/changelog/archive-15.md +181 -0
- package/docs/changelog/archive-16.md +182 -0
- package/docs/changelog/archive-17.md +183 -0
- package/docs/changelog/archive-18.md +55 -0
- package/docs/distilled-interim.md +15 -3
- package/docs/provider-standard.md +24 -6
- package/docs/proxmox.md +26 -0
- package/docs/upstream-conformance-bun.md +67 -0
- package/docs/upstream-conformance-conforms.md +55 -0
- package/docs/upstream-conformance-families.md +95 -0
- package/docs/upstream-conformance-grafana.md +106 -0
- package/docs/upstream-conformance.md +20 -298
- package/package.json +3 -3
- package/src/proxmox/ceph-daemon-wire.ts +4 -7
- package/src/proxmox/ceph-daemon.ts +7 -8
- package/src/proxmox/ceph-fs-delete.test.ts +5 -8
- package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
- package/src/proxmox/ceph-fs-distilled.ts +45 -19
- package/src/proxmox/ceph-fs-wire.ts +5 -93
- package/src/proxmox/ceph-fs.ts +13 -15
- package/src/proxmox/ceph-pool-settle.ts +11 -10
- package/src/proxmox/ceph-pool-wire.ts +11 -20
- package/src/proxmox/ceph-pool-write.test.ts +9 -8
- package/src/proxmox/group-wire.ts +7 -12
- package/src/proxmox/read-failure-propagation.test.ts +5 -5
- package/src/proxmox/storage-wire.ts +9 -10
- package/src/proxmox/typed-absence.test.ts +146 -0
- package/src/proxmox/unreadable-read.ts +7 -7
- package/src/proxmox/user-wire.ts +7 -10
- package/src/proxmox/user.ts +5 -5
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.CephFs`'s distilled
|
|
3
|
-
*
|
|
2
|
+
* `Proxmox.CephFs`'s distilled transport: index, create, delete and task status.
|
|
3
|
+
* SDK 0.3.0 fixes DELETE query flags and supplies the measured CephFsNotFound tag.
|
|
4
4
|
*
|
|
5
5
|
* ⛔ `nodes.updateNodeCephFs` IS distilled's GENERATOR MISNAMING A CREATE, NOT A SECOND ENDPOINT.
|
|
6
6
|
* Checked directly against its schema (`T.Http({ method: "POST", uri:
|
|
7
7
|
* "/nodes/{node}/ceph/fs/{name}" })`) rather than trusted from the function name — it is the
|
|
8
8
|
* SAME single write PVE registers, `createfs`. Harmless once named here for what it is.
|
|
9
9
|
*
|
|
10
|
-
* ★
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* settled yet, not a failure" tolerance. That tolerance is why this calls the RAW distilled
|
|
15
|
-
* operation directly rather than `@distilled.cloud/proxmox`'s own `Task.awaitTask` — CHECKED
|
|
16
|
-
* against its source (`task.ts`): it propagates a poll failure as a genuine typed
|
|
17
|
-
* `GetNodeTaskStatusError` instead of treating it as "not yet", which would turn a missing
|
|
18
|
-
* `Sys.Audit` grant or a node mid-restart into a failed create instead of a patient one.
|
|
10
|
+
* ★ CREATE AND DELETE SHARE THE SAME BOUNDED POLL POLICY. The raw getNodeTaskStatus
|
|
11
|
+
* operation preserves this family's tolerance of a missing Sys.Audit grant or a node
|
|
12
|
+
* mid-restart until the deadline. The former SDK Task.awaitTask propagated poll failures;
|
|
13
|
+
* SDK PR #265 removed it because polling policy belongs in the provider.
|
|
19
14
|
*/
|
|
20
15
|
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
21
16
|
import * as Effect from 'effect/Effect';
|
|
@@ -26,15 +21,14 @@ import { runPve } from './distilled-pve.ts';
|
|
|
26
21
|
/**
|
|
27
22
|
* The index, read via distilled — the same shape `readRow` (ceph-fs-wire.ts) already narrows.
|
|
28
23
|
*
|
|
29
|
-
* ⛔
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
24
|
+
* ⛔ ABSENCE IS A SUCCESSFUL INDEX WITH NO MATCHING FILESYSTEM. The transport swap
|
|
25
|
+
* initially preserved the generic factory's catch-all fold. The 2026-09-24 follow-up removes it:
|
|
26
|
+
* a failed list is not proof that a filesystem is missing. Task polling below retains its
|
|
27
|
+
* separate bounded wait policy; it does not decide whether a resource exists.
|
|
33
28
|
*/
|
|
34
29
|
export const readFs = (props: CephFsProps) =>
|
|
35
30
|
runPve(props.target, 'read', false, nodes.listNodeCephFs({ node: props.node })).pipe(
|
|
36
31
|
Effect.map((rows) => readRow(rows as unknown as Record<string, unknown>, props)),
|
|
37
|
-
Effect.orElseSucceed(() => undefined),
|
|
38
32
|
);
|
|
39
33
|
|
|
40
34
|
/** `updateNodeCephFs`'s own request shape — `createForm`, translated. No field is renamed. */
|
|
@@ -45,8 +39,11 @@ const toDistilledCreate = (props: CephFsProps): nodes.UpdateNodeCephFsRequest =>
|
|
|
45
39
|
node: props.node,
|
|
46
40
|
}) as unknown as nodes.UpdateNodeCephFsRequest;
|
|
47
41
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Wait for a forked task. `exitstatus` is absent while running and exactly `OK` on success.
|
|
44
|
+
* ⚠️ T.Label encodes the colon-filled UPID as one path segment, as the hand client did.
|
|
45
|
+
*/
|
|
46
|
+
const settleTask = (props: CephFsProps, upid: string) =>
|
|
50
47
|
Effect.gen(function* () {
|
|
51
48
|
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
52
49
|
const status = yield* runPve(
|
|
@@ -98,5 +95,34 @@ export const createFs = (props: CephFsProps) =>
|
|
|
98
95
|
),
|
|
99
96
|
);
|
|
100
97
|
}
|
|
101
|
-
yield*
|
|
98
|
+
yield* settleTask(props, upid);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* DELETE the filesystem, then wait for the worker.
|
|
103
|
+
*
|
|
104
|
+
* ⛔ WHAT THIS TAKES AWAY DEPENDS ENTIRELY ON `remove-pools`, AND THE DEFAULT IS THE SAFE ONE. With
|
|
105
|
+
* it unset PVE removes the filesystem from the MDS map and LEAVES the two pools on disk: every
|
|
106
|
+
* mount breaks, but the bytes are still there for an operator to recover. With it set the pools
|
|
107
|
+
* go too and nothing is recoverable. Leaving it unset also means a later create of the SAME name
|
|
108
|
+
* fails with "ceph pools '<name>_data' and/or '<name>_metadata' already exist" -- that refusal is
|
|
109
|
+
* a BRAKE, not a bug, and it is the last thing standing between a mis-typed rename and the data.
|
|
110
|
+
*/
|
|
111
|
+
export const destroyFs = (props: CephFsProps) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const upid = yield* runPve(
|
|
114
|
+
props.target,
|
|
115
|
+
'provision',
|
|
116
|
+
true,
|
|
117
|
+
nodes.deleteNodeCephFs({
|
|
118
|
+
node: props.node,
|
|
119
|
+
name: props.name,
|
|
120
|
+
// ⛔ Query fields, never a DELETE body. Omitted/false retains PVE's safe default.
|
|
121
|
+
...(props['remove-pools'] === true ? { remove_pools: '1' } : {}),
|
|
122
|
+
...(props['remove-storages'] === true ? { remove_storages: '1' } : {}),
|
|
123
|
+
}),
|
|
124
|
+
).pipe(Effect.catchTag('CephFsNotFound', () => Effect.succeed('')));
|
|
125
|
+
// ⚠️ Missing already is idempotent; every other SDK error propagates. The caller's
|
|
126
|
+
// successful index read must still confirm absence after the DELETE or task.
|
|
127
|
+
if (upid !== '') yield* settleTask(props, upid);
|
|
102
128
|
});
|
|
@@ -49,21 +49,13 @@
|
|
|
49
49
|
* Grant it on the `read` role, or every create burns the full cap below and reports the wrong
|
|
50
50
|
* cause.
|
|
51
51
|
*
|
|
52
|
-
* ★
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (network-apply-read.ts's own header has the fuller reasoning). `destroyFs` stays HERE,
|
|
58
|
-
* unmigrated: distilled types `remove_pools`/`remove_storages` as `T.Body` on a DELETE, and the
|
|
59
|
-
* ⛔ two paragraphs up is the MEASURED reason PVE would silently ignore both flags if sent that
|
|
60
|
-
* way — a real protocol gap, not a judgement call. `settle` below is still shared where it can
|
|
61
|
-
* be: `createForm`/`objectPath`/`destroyPath` are pure and reused by the distilled file too.
|
|
52
|
+
* ★ THE ORIGINAL DISTILLED WALK MOVED READ AND CREATE FIRST, leaving DELETE here because
|
|
53
|
+
* SDK flags were annotated as body fields. Distilled Proxmox 0.3.0 fixes their query binding
|
|
54
|
+
* and adds the measured CephFsNotFound tag. All calls now live in ceph-fs-distilled.ts;
|
|
55
|
+
* this file keeps the pure forms, attributes and read-back failure messages. The protocol
|
|
56
|
+
* history above remains load-bearing: moving a DELETE flag back into a body loses it silently.
|
|
62
57
|
*/
|
|
63
|
-
import * as Effect from 'effect/Effect';
|
|
64
58
|
import type { CephFsAttributes, CephFsProps } from './ceph-fs.ts';
|
|
65
|
-
import { pve } from './client.ts';
|
|
66
|
-
import type { PveTarget } from './credentials.ts';
|
|
67
59
|
import { csv, flag, int, text } from './values.ts';
|
|
68
60
|
|
|
69
61
|
/**
|
|
@@ -100,9 +92,6 @@ export const readRow = (
|
|
|
100
92
|
};
|
|
101
93
|
};
|
|
102
94
|
|
|
103
|
-
/** ⚠️ `exitstatus` is ABSENT while a task runs, and is the exact string `OK` only on success. */
|
|
104
|
-
type TaskStatus = { readonly status?: string; readonly exitstatus?: string };
|
|
105
|
-
|
|
106
95
|
/**
|
|
107
96
|
* ⚠️ SHORT POLLS, LOW CAP. Ninety seconds covers two pool creates plus the ten seconds `createfs`
|
|
108
97
|
* itself spends waiting for an MDS to go active, and is short enough that a stuck task fails the
|
|
@@ -131,83 +120,6 @@ export const createForm = (props: CephFsProps): Record<string, string> => ({
|
|
|
131
120
|
...field('pg_num', props.pg_num === undefined ? undefined : String(props.pg_num)),
|
|
132
121
|
});
|
|
133
122
|
|
|
134
|
-
/** The delete target with its flags in the query string — see the ⛔ in the header. */
|
|
135
|
-
const destroyPath = (props: CephFsProps) => {
|
|
136
|
-
const query = [
|
|
137
|
-
props['remove-pools'] === true ? 'remove-pools=1' : undefined,
|
|
138
|
-
props['remove-storages'] === true ? 'remove-storages=1' : undefined,
|
|
139
|
-
].filter((part) => part !== undefined);
|
|
140
|
-
return query.length === 0 ? objectPath(props) : `${objectPath(props)}?${query.join('&')}`;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Wait for a forked PVE task, and fail loudly rather than quietly.
|
|
145
|
-
*
|
|
146
|
-
* ⚠️ THE UPID IS PERCENT-ENCODED BECAUSE IT IS FULL OF COLONS — `UPID:node-b:00396D3A:…:root@pam:`
|
|
147
|
-
* (MEASURED shape, from `GET /nodes/node-b/tasks`) is ONE path segment, not seven. Encoding is what
|
|
148
|
-
* guarantees it arrives as one; PVE's router decodes each segment before matching. REASONED from
|
|
149
|
-
* the URI handling rather than measured — no task of this provider's has been polled yet.
|
|
150
|
-
*/
|
|
151
|
-
const settle = (target: PveTarget, node: string, upid: string, what: string) =>
|
|
152
|
-
Effect.gen(function* () {
|
|
153
|
-
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
154
|
-
const status = yield* pve<TaskStatus>(
|
|
155
|
-
target,
|
|
156
|
-
'read',
|
|
157
|
-
'GET',
|
|
158
|
-
`nodes/${node}/tasks/${encodeURIComponent(upid)}/status`,
|
|
159
|
-
).pipe(
|
|
160
|
-
// ⚠️ AN UNREADABLE STATUS IS "NOT SETTLED YET", NOT A FAILURE. It is a missing Sys.Audit
|
|
161
|
-
// (see the header) or a node mid-restart; either way the task is still the cluster's
|
|
162
|
-
// business, and the cap is what ends the wait rather than one unlucky GET.
|
|
163
|
-
Effect.orElseSucceed(() => undefined),
|
|
164
|
-
);
|
|
165
|
-
if (status?.status === 'stopped') {
|
|
166
|
-
const exit = text(status.exitstatus);
|
|
167
|
-
if (exit === 'OK') return;
|
|
168
|
-
return yield* Effect.die(
|
|
169
|
-
new Error(
|
|
170
|
-
`${what}: PVE task ${upid} finished with "${exit === '' ? 'no exit status' : exit}". ` +
|
|
171
|
-
'The write returned HTTP 200 because it only forked the worker -- the real error is ' +
|
|
172
|
-
`in \`pvesh get /nodes/${node}/tasks/${upid}/log\`.`,
|
|
173
|
-
),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
// ⚠️ A BARE NUMBER IS MILLISECONDS to Effect's Duration, not seconds. The cap in the
|
|
177
|
-
// message below is derived from the same constant so the two cannot drift apart.
|
|
178
|
-
yield* Effect.sleep(POLL_SECONDS * 1000);
|
|
179
|
-
}
|
|
180
|
-
return yield* Effect.die(
|
|
181
|
-
new Error(
|
|
182
|
-
`${what}: PVE task ${upid} was still running after ` +
|
|
183
|
-
`${String(POLL_ATTEMPTS * POLL_SECONDS)}s, or its ` +
|
|
184
|
-
'status could not be read. Check the task log, and check that the `read` role holds ' +
|
|
185
|
-
`Sys.Audit on /nodes/${node} -- each call mints a new token, so the poller is never the ` +
|
|
186
|
-
'task owner and is answered 403 rather than told to wait.',
|
|
187
|
-
),
|
|
188
|
-
);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* DELETE the filesystem, then wait for the worker.
|
|
193
|
-
*
|
|
194
|
-
* ⛔ WHAT THIS TAKES AWAY DEPENDS ENTIRELY ON `remove-pools`, AND THE DEFAULT IS THE SAFE ONE. With
|
|
195
|
-
* it unset PVE removes the filesystem from the MDS map and LEAVES the two pools on disk: every
|
|
196
|
-
* mount breaks, but the bytes are still there for an operator to recover. With it set the pools
|
|
197
|
-
* go too and nothing is recoverable. Leaving it unset also means a later create of the SAME name
|
|
198
|
-
* fails with "ceph pools '<name>_data' and/or '<name>_metadata' already exist" -- that refusal is
|
|
199
|
-
* a BRAKE, not a bug, and it is the last thing standing between a mis-typed rename and the data.
|
|
200
|
-
*/
|
|
201
|
-
export const destroyFs = (props: CephFsProps) =>
|
|
202
|
-
Effect.gen(function* () {
|
|
203
|
-
const upid = text(yield* pve<string>(props.target, 'provision', 'DELETE', destroyPath(props)));
|
|
204
|
-
// ⚠️ A DESTROY THAT ANSWERS WITHOUT A UPID IS NOT REFUSED HERE. `destroyfs` dies synchronously
|
|
205
|
-
// with "no such cephfs" when the filesystem is already gone, which surfaces as a failed
|
|
206
|
-
// DELETE; an empty answer that is not an error is best treated as already-absent, and the
|
|
207
|
-
// caller's read-back is what proves it either way.
|
|
208
|
-
if (upid !== '') yield* settle(props.target, props.node, upid, destroyPath(props));
|
|
209
|
-
});
|
|
210
|
-
|
|
211
123
|
/**
|
|
212
124
|
* The two read-back failures, as messages rather than as guesses.
|
|
213
125
|
*
|
package/src/proxmox/ceph-fs.ts
CHANGED
|
@@ -28,12 +28,11 @@
|
|
|
28
28
|
*
|
|
29
29
|
* ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
|
|
30
30
|
* `nodes.listNodeCephFs`/`updateNodeCephFs` for the READ and the CREATE (2026-09-24, decision
|
|
31
|
-
* 43's walk-down, 2c — ceph-fs-distilled.ts).
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* family always had; see ceph-pool-wire.ts's own header for why that is not a bug left unfixed.
|
|
31
|
+
* 43's walk-down, 2c — ceph-fs-distilled.ts). DELETE originally stayed on `client.ts`:
|
|
32
|
+
* the SDK put its flags in a body that PVE discards (ceph-fs-wire.ts's history).
|
|
33
|
+
* SDK 0.3.0 fixes the query binding; delete now uses it and catches only CephFsNotFound.
|
|
34
|
+
* The typed-read follow-up removes the catch-all fold from `readFs`: a failed index must
|
|
35
|
+
* fail read/diff/reconcile/delete, not report a missing filesystem or a successful destroy.
|
|
37
36
|
*
|
|
38
37
|
* ⛔ CREATING A CephFS CREATES TWO CEPH POOLS, AND A `Proxmox.CephPool` MUST NOT ALSO DECLARE THEM.
|
|
39
38
|
* `createfs` builds `<name>_data` and `<name>_metadata` itself and refuses outright if either
|
|
@@ -68,8 +67,8 @@ import { Resource } from 'alchemy';
|
|
|
68
67
|
import { isResolved } from 'alchemy/Diff';
|
|
69
68
|
import * as Provider from 'alchemy/Provider';
|
|
70
69
|
import * as Effect from 'effect/Effect';
|
|
71
|
-
import { createFs, readFs } from './ceph-fs-distilled.ts';
|
|
72
|
-
import { CEPH_FS_CREATE, createForm,
|
|
70
|
+
import { createFs, destroyFs, readFs } from './ceph-fs-distilled.ts';
|
|
71
|
+
import { CEPH_FS_CREATE, createForm, notCreated, notDestroyed } from './ceph-fs-wire.ts';
|
|
73
72
|
import { guardWrite } from './distilled-guard.ts';
|
|
74
73
|
import { type PveRequirements, type WithTarget } from './resource.ts';
|
|
75
74
|
|
|
@@ -223,19 +222,18 @@ export const ProxmoxCephFsProvider = () =>
|
|
|
223
222
|
* ⛔ THE READ-BACK IS WHAT TURNS "the task said OK" INTO "it is actually gone": `destroyfs`
|
|
224
223
|
* refuses while a non-disabled `cephfs` storage still references the filesystem, and that
|
|
225
224
|
* refusal must fail the destroy rather than letting Alchemy drop the state entry for a
|
|
226
|
-
* live object. `destroyFs`
|
|
227
|
-
* the measured
|
|
225
|
+
* live object. `destroyFs` now uses the SDK query binding — ceph-fs-wire.ts preserves
|
|
226
|
+
* the measured DELETE query-binding history.
|
|
228
227
|
*/
|
|
229
228
|
delete: Effect.fn(function* ({ olds }) {
|
|
230
229
|
/**
|
|
231
230
|
* ⚠️ A FILESYSTEM SOMEBODY ALREADY REMOVED BY HAND IS NOT AN ERROR. `destroyfs` dies
|
|
232
231
|
* synchronously with "no such cephfs", which would fail every destroy of a stack whose
|
|
233
232
|
* filesystem was cleaned up outside Alchemy and leave the state entry unremovable
|
|
234
|
-
* without editing the store.
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* it is the reason a 403 on this family must be fixed rather than lived with.
|
|
233
|
+
* without editing the store. The original catch-all read fold could also report
|
|
234
|
+
* success after a 403 without deleting anything, and could defeat read-back after a
|
|
235
|
+
* write. `readFs` now propagates those failures: only a successful index with no
|
|
236
|
+
* matching row may short-circuit this delete or confirm the filesystem is gone.
|
|
239
237
|
*/
|
|
240
238
|
if ((yield* readFs(olds)) === undefined) return;
|
|
241
239
|
yield* destroyFs(olds);
|
|
@@ -17,18 +17,19 @@ import type { PveRequirements } from './resource.ts';
|
|
|
17
17
|
* separable at all. `ops.read` — now `readPoolStatus` (ceph-pool-wire.ts) — is a module-level
|
|
18
18
|
* binding in ceph-pool.ts; importing it back would be a runtime cycle, and duplicating it would
|
|
19
19
|
* be two definitions of one path. Passing it is the pattern zfs-pool-write.ts already uses.
|
|
20
|
+
* The error parameter preserves the reader's typed failures through settle; it is not `never`.
|
|
20
21
|
*/
|
|
21
|
-
export type PoolRead = (
|
|
22
|
+
export type PoolRead<E> = (
|
|
22
23
|
props: CephPoolProps,
|
|
23
|
-
) => Effect.Effect<CephPoolAttributes | undefined,
|
|
24
|
+
) => Effect.Effect<CephPoolAttributes | undefined, E, PveRequirements>;
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* ⛔ THE GUARD ON THE CREATE.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* An empty list is trusted: the collection
|
|
31
|
-
* filtering, so `[]` really does mean a cluster with no pools.
|
|
27
|
+
* ⛔ THE GUARD ON THE CREATE. The original read folded 403s/timeouts into absence, so this
|
|
28
|
+
* independent index check prevented creating over a live pool. The typed reader now
|
|
29
|
+
* propagates those failures, but a `CephPoolNotFound` must still not override a listed pool:
|
|
30
|
+
* the measured PG-merge risk warrants keeping both observations. Failed index reads also
|
|
31
|
+
* propagate. An empty list is trusted: the collection refuses a narrow role with 403 rather
|
|
32
|
+
* than filtering, so `[]` really does mean a cluster with no pools.
|
|
32
33
|
*
|
|
33
34
|
* ⚠️ `describe` IS STILL A PLAIN STRING (ceph-pool.ts builds it from `object`/`collection` in
|
|
34
35
|
* ceph-pool-form.ts) — this function no longer takes a `collection` PATH, since
|
|
@@ -61,9 +62,9 @@ export const confirmAbsent = (props: CephPoolProps, describe: string) =>
|
|
|
61
62
|
* ⚠️ SHORT POLLS, LOW CAP: 2s apart, 30 tries, about a minute. A pool create takes seconds; one
|
|
62
63
|
* still unsettled after a minute is a fault to surface, not a wait to lengthen.
|
|
63
64
|
*/
|
|
64
|
-
export const settle = (
|
|
65
|
+
export const settle = <E>(
|
|
65
66
|
props: CephPoolProps,
|
|
66
|
-
read: PoolRead
|
|
67
|
+
read: PoolRead<E>,
|
|
67
68
|
done: (live: CephPoolAttributes | undefined) => boolean,
|
|
68
69
|
) =>
|
|
69
70
|
Effect.gen(function* () {
|
|
@@ -7,22 +7,13 @@
|
|
|
7
7
|
* PVE form, unaware distilled exists: this file is the ONLY place that translates one into a
|
|
8
8
|
* typed distilled request.
|
|
9
9
|
*
|
|
10
|
-
* ⛔
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* (`Effect.orElseSucceed(() => undefined)`, no `output`-branching, checked directly against
|
|
18
|
-
* `resource.ts` 2026-09-24). This migration's own rule is that every existing refusal and guard
|
|
19
|
-
* survives byte-for-byte — not that it gets upgraded on the way past. `readPoolStatus` below
|
|
20
|
-
* reproduces that exact single-fold shape; it does not add the newer pattern.
|
|
21
|
-
* ⚠️ WORTH A LATER, SEPARATELY-SCOPED DECISION: this means CephPool (like CephDaemon) still folds
|
|
22
|
-
* a refused OpenBao mint into "absent" too, same as every other pre-`unreadable-read.ts` family
|
|
23
|
-
* — the cries-wolf class `Proxmox.NodeNetwork`/`Proxmox.ZfsPool`/`Proxmox.Storage` were fixed
|
|
24
|
-
* against. Noted here rather than fixed here, since fixing it is a behaviour change this PR's
|
|
25
|
-
* own rules do not ask for.
|
|
10
|
+
* ⛔ ONLY TYPED ABSENCE MAY REACH A CREATE. The transport migration deliberately preserved
|
|
11
|
+
* `pveOperations.read`'s catch-all `Effect.orElseSucceed(() => undefined)` to avoid changing
|
|
12
|
+
* behavior during the swap. That also hid refused credentials and transient failures, risking
|
|
13
|
+
* a false update/create. SDK PR #265 added the measured `CephPoolNotFound` tag; the follow-up
|
|
14
|
+
* now catches only it. One reader serves read/diff/reconcile/settle, so all propagate other
|
|
15
|
+
* failures. `confirmAbsent` still checks the index before create: a missing status alone must
|
|
16
|
+
* never override a pool that the index lists (the PG-merge incident, ceph-pool-settle.ts).
|
|
26
17
|
*/
|
|
27
18
|
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
28
19
|
import * as Effect from 'effect/Effect';
|
|
@@ -72,9 +63,9 @@ const attributesOf = (live: unknown, props: CephPoolProps): CephPoolAttributes |
|
|
|
72
63
|
};
|
|
73
64
|
|
|
74
65
|
/**
|
|
75
|
-
* The live pool, or `undefined`
|
|
76
|
-
*
|
|
77
|
-
*
|
|
66
|
+
* The live pool, or `undefined` only for the SDK's measured `CephPoolNotFound`. The initial
|
|
67
|
+
* transport swap preserved `ops.read`'s catch-all fold; the 2026-09-24 follow-up removes that ambiguity.
|
|
68
|
+
* Read, reconcile and settle now propagate every other failure instead of assuming absence.
|
|
78
69
|
*
|
|
79
70
|
* ⛔ `verbose=1` IS WHAT MAKES `applications` VISIBLE AT ALL — ceph-pool.ts's own header has the
|
|
80
71
|
* measured reason and the cost (two extra, unguarded mon commands PVE runs to answer it).
|
|
@@ -87,7 +78,7 @@ export const readPoolStatus = (props: CephPoolProps) =>
|
|
|
87
78
|
nodes.getNodeCephPoolStatus({ name: props.name, node: props.node, verbose: '1' }),
|
|
88
79
|
).pipe(
|
|
89
80
|
Effect.map((live) => attributesOf(live, props)),
|
|
90
|
-
Effect.
|
|
81
|
+
Effect.catchTag('CephPoolNotFound', () => Effect.succeed(undefined)),
|
|
91
82
|
);
|
|
92
83
|
|
|
93
84
|
/** The two endpoints, as the keys `guardWrite` (distilled-guard.ts) checks both forms against. */
|
|
@@ -6,12 +6,10 @@
|
|
|
6
6
|
* ★ THE ABSENCE SIGNAL IS THE MEASURED ONE. `GET .../pool/{name}/status` on a name with no pool
|
|
7
7
|
* answers HTTP 500 (live TB4 `n2`, 2026-09-24, read-role, read-only probe:
|
|
8
8
|
* `{"data":null,"message":"error with 'osd pool get': mon_cmd failed - unrecognized pool
|
|
9
|
-
* '<name>'\n"}`) —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* answer anything but HTTP 200, so this file builds its own stub the way
|
|
14
|
-
* zfs-pool-read-failure.test.ts does, to answer the measured 500 directly.
|
|
9
|
+
* '<name>'\n"}`) — SDK PR #265 now recognizes it as `CephPoolNotFound`. Only that tag folds
|
|
10
|
+
* to absence; unrelated 500s propagate. `confirmAbsent` (ceph-pool-settle.ts) still asks a
|
|
11
|
+
* SECOND, different question — does the index list this name — before create may run.
|
|
12
|
+
* `fakePve` only answers HTTP 200, so this file stubs the measured 500 directly.
|
|
15
13
|
*/
|
|
16
14
|
import { describe, expect, test } from 'bun:test';
|
|
17
15
|
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
@@ -28,10 +26,13 @@ const INDEX = `nodes/${NODE}/ceph/pool`;
|
|
|
28
26
|
const OBJECT = `${INDEX}/${NAME}`;
|
|
29
27
|
const STATUS = `${OBJECT}/status`;
|
|
30
28
|
|
|
31
|
-
/**
|
|
29
|
+
/** The measured missing-pool 500, classified by the SDK as `CephPoolNotFound`. */
|
|
32
30
|
const absent = () =>
|
|
33
31
|
Response.json(
|
|
34
|
-
{
|
|
32
|
+
{
|
|
33
|
+
data: null,
|
|
34
|
+
message: `error with 'osd pool get': mon_cmd failed - unrecognized pool '${NAME}'\n`,
|
|
35
|
+
},
|
|
35
36
|
{ status: 500 },
|
|
36
37
|
);
|
|
37
38
|
|
|
@@ -97,20 +97,15 @@ export const readGroupOrFail = (props: GroupProps) =>
|
|
|
97
97
|
);
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* ⛔
|
|
101
|
-
* PVE
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
|
|
107
|
-
* credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
|
|
108
|
-
* above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
|
|
109
|
-
* compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
|
|
110
|
-
* propagates and fails the whole plan loudly — never silently misreports one row.
|
|
100
|
+
* ⛔ ONLY `GroupNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
|
|
101
|
+
* catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
|
|
102
|
+
* measured message, so permission errors, unrelated 500s and exhausted transports propagate
|
|
103
|
+
* instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
|
|
104
|
+
* ★ `readGroupOrFail` remains the strict path used for an already-confirmed state row. This
|
|
105
|
+
* change narrows cold reads/reconcile without changing that existing drift/diff contract.
|
|
111
106
|
*/
|
|
112
107
|
export const readGroup = (props: GroupProps) =>
|
|
113
|
-
readGroupOrFail(props).pipe(Effect.
|
|
108
|
+
readGroupOrFail(props).pipe(Effect.catchTag('GroupNotFound', () => Effect.succeed(undefined)));
|
|
114
109
|
|
|
115
110
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
116
111
|
export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* field, never the whole declared props object — the root cause: distilled's `buildRequest`
|
|
9
9
|
* treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
|
|
10
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"
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent".
|
|
12
|
+
* The later typed-absence follow-up extends that protection to cold `read`/`reconcile`:
|
|
13
|
+
* only the SDK's resource-specific missing-object tags may trigger a create.
|
|
14
14
|
*/
|
|
15
15
|
import { describe, expect, test } from 'bun:test';
|
|
16
16
|
import * as Layer from 'effect/Layer';
|
|
@@ -116,8 +116,8 @@ describe('a transient read failure fails the plan loudly, never a silent false u
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
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
|
|
120
|
-
//
|
|
119
|
+
// user.test.ts's `missingAs500` fixture. The typed-absence follow-up keeps that path by
|
|
120
|
+
// catching `UserNotFound`, while unrelated 500s now propagate on cold reads too.
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
|
|
@@ -93,18 +93,17 @@ export const readStorageOrFail = (props: StorageProps) =>
|
|
|
93
93
|
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* ⛔
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* nothing compared (the ORIGINAL cries-wolf bug this family shipped with, per unreadable-read.ts's
|
|
103
|
-
* own header). `diff` calls `readStorageOrFail` directly, so a genuine failure there propagates
|
|
104
|
-
* and fails the whole plan loudly instead.
|
|
96
|
+
* ⛔ ONLY `StorageNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
|
|
97
|
+
* catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
|
|
98
|
+
* measured message, so permission errors, unrelated 500s and exhausted transports propagate
|
|
99
|
+
* instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
|
|
100
|
+
* ★ `readStorageOrFail` remains the strict path used for an already-confirmed state row. This
|
|
101
|
+
* change narrows cold reads/reconcile without changing that existing drift/diff contract.
|
|
105
102
|
*/
|
|
106
103
|
export const readStorage = (props: StorageProps) =>
|
|
107
|
-
readStorageOrFail(props).pipe(
|
|
104
|
+
readStorageOrFail(props).pipe(
|
|
105
|
+
Effect.catchTag('StorageNotFound', () => Effect.succeed(undefined)),
|
|
106
|
+
);
|
|
108
107
|
|
|
109
108
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
110
109
|
export const dropUnreadable = (live: StorageAttributes | Unreadable | undefined) =>
|