@homeflare/alchemy 0.36.0 → 0.37.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.
- package/README.md +14 -0
- package/dist/argocd/cluster.d.ts +102 -0
- package/dist/argocd/cluster.d.ts.map +1 -0
- package/dist/argocd/credentials.d.ts +37 -0
- package/dist/argocd/credentials.d.ts.map +1 -0
- package/dist/argocd/fake-argocd.d.ts +42 -0
- package/dist/argocd/fake-argocd.d.ts.map +1 -0
- package/dist/argocd/git-credentials.d.ts +29 -0
- package/dist/argocd/git-credentials.d.ts.map +1 -0
- package/dist/argocd/index.d.ts +19 -0
- package/dist/argocd/index.d.ts.map +1 -0
- package/dist/argocd/index.js +306 -0
- package/dist/argocd/index.js.map +17 -0
- package/dist/argocd/providers.d.ts +13 -0
- package/dist/argocd/providers.d.ts.map +1 -0
- package/dist/argocd/repo-creds.d.ts +67 -0
- package/dist/argocd/repo-creds.d.ts.map +1 -0
- package/dist/argocd/repository.d.ts +87 -0
- package/dist/argocd/repository.d.ts.map +1 -0
- package/dist/argocd/resource.d.ts +90 -0
- package/dist/argocd/resource.d.ts.map +1 -0
- package/dist/cloudflare/index.js +4 -4
- package/dist/cloudflare/index.js.map +3 -3
- package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/litellm/index.js.map +2 -2
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-wire.d.ts +31 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +40 -6
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +33 -0
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +30 -6
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +17 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd.d.ts +6 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +19 -12
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +37 -0
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +10 -5
- package/dist/proxmox/ceph-pool.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 +605 -483
- package/dist/proxmox/index.js.map +20 -17
- package/dist/proxmox/storage-wire.d.ts +15 -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/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/distilled-interim.md +15 -3
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/provider-standard.md +24 -6
- package/docs/proxmox.md +20 -0
- package/docs/unifi.md +45 -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 +8 -3
- package/src/argocd/cluster.test.ts +120 -0
- package/src/argocd/cluster.ts +222 -0
- package/src/argocd/credentials.ts +38 -0
- package/src/argocd/fake-argocd.ts +60 -0
- package/src/argocd/git-credentials.ts +71 -0
- package/src/argocd/index.ts +40 -0
- package/src/argocd/providers.ts +17 -0
- package/src/argocd/repo-creds.test.ts +123 -0
- package/src/argocd/repo-creds.ts +115 -0
- package/src/argocd/repository.test.ts +167 -0
- package/src/argocd/repository.ts +165 -0
- package/src/argocd/resource.ts +150 -0
- package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
- package/src/cloudflare/r2-bucket-lock.ts +25 -18
- package/src/litellm/operations.ts +8 -4
- package/src/proxmox/ceph-daemon-form.ts +7 -8
- package/src/proxmox/ceph-daemon-wire.ts +112 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +70 -53
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +100 -0
- package/src/proxmox/ceph-fs-wire.ts +17 -31
- package/src/proxmox/ceph-fs-write.test.ts +67 -0
- package/src/proxmox/ceph-fs.ts +68 -73
- package/src/proxmox/ceph-osd-tree.ts +14 -5
- package/src/proxmox/ceph-osd-write.test.ts +61 -0
- package/src/proxmox/ceph-osd-write.ts +33 -6
- package/src/proxmox/ceph-osd.ts +6 -0
- package/src/proxmox/ceph-pool-settle.ts +24 -17
- package/src/proxmox/ceph-pool-wire.ts +101 -0
- package/src/proxmox/ceph-pool-write.test.ts +146 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- 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
- package/src/proxmox/distilled-task-await.test.ts +0 -124
package/src/proxmox/ceph-fs.ts
CHANGED
|
@@ -19,12 +19,20 @@
|
|
|
19
19
|
* below returns the INDEX and `attributes` picks this filesystem out of the array client-side —
|
|
20
20
|
* the shape acl.ts uses for the one flat `GET /access/acl`. The POST goes to the `{name}` path,
|
|
21
21
|
* so `collection()` is the LONGER of the two strings, the mirror of metric-server.ts.
|
|
22
|
-
* ⚠️ AND THAT IS WHY
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* ⚠️ AND THAT IS WHY EVERY HANDLER BELOW IS HAND-WRITTEN rather than taken from a factory. A
|
|
23
|
+
* factory `destroy` deletes the read path, which here is the index: `DELETE /nodes/{node}/
|
|
24
|
+
* ceph/fs` is not a route, so it would fail every destroy while the filesystem stayed exactly
|
|
25
|
+
* where it was. `reconcile` is hand-written for a second, independent reason — the forked
|
|
26
|
+
* worker — set out in ceph-fs-wire.ts. acl.ts is the precedent for a family that genuinely
|
|
27
|
+
* does not fit a shared shape.
|
|
28
|
+
*
|
|
29
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
|
|
30
|
+
* `nodes.listNodeCephFs`/`updateNodeCephFs` for the READ and the CREATE (2026-09-24, decision
|
|
31
|
+
* 43's walk-down, 2c — ceph-fs-distilled.ts). `destroyFs` STAYS on `client.ts`, unmigrated:
|
|
32
|
+
* the SDK originally put `remove-pools`/`remove-storages` in a DELETE body, which PVE ignores.
|
|
33
|
+
* SDK PR #265 fixes that protocol gap; migrating `destroyFs` remains a separate change.
|
|
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.
|
|
28
36
|
*
|
|
29
37
|
* ⛔ CREATING A CephFS CREATES TWO CEPH POOLS, AND A `Proxmox.CephPool` MUST NOT ALSO DECLARE THEM.
|
|
30
38
|
* `createfs` builds `<name>_data` and `<name>_metadata` itself and refuses outright if either
|
|
@@ -59,16 +67,10 @@ import { Resource } from 'alchemy';
|
|
|
59
67
|
import { isResolved } from 'alchemy/Diff';
|
|
60
68
|
import * as Provider from 'alchemy/Provider';
|
|
61
69
|
import * as Effect from 'effect/Effect';
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
notCreated,
|
|
67
|
-
notDestroyed,
|
|
68
|
-
objectPath,
|
|
69
|
-
readRow,
|
|
70
|
-
} from './ceph-fs-wire.ts';
|
|
71
|
-
import { type PveRequirements, type WithTarget, pveOperations } from './resource.ts';
|
|
70
|
+
import { createFs, readFs } from './ceph-fs-distilled.ts';
|
|
71
|
+
import { CEPH_FS_CREATE, createForm, destroyFs, notCreated, notDestroyed } from './ceph-fs-wire.ts';
|
|
72
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
73
|
+
import { type PveRequirements, type WithTarget } from './resource.ts';
|
|
72
74
|
|
|
73
75
|
export interface CephFsProps extends WithTarget {
|
|
74
76
|
/** ⚠️ Which node answers the call, NOT which node holds the filesystem. See the header. */
|
|
@@ -143,33 +145,22 @@ export const ProxmoxCephFs = Resource<ProxmoxCephFs>('Proxmox.CephFs', {
|
|
|
143
145
|
defaultRemovalPolicy: 'retain',
|
|
144
146
|
});
|
|
145
147
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* rebuild a live filesystem on every deploy, triggered by a daemon on its own schedule.
|
|
163
|
-
* ⚠️ `data_pools` AND `metadata_pool_id` ARE OUT FOR THE ORDINARY REASONS: a set PVE returns
|
|
164
|
-
* in its own order, and an integer id for something written as a name. `name` is out because
|
|
165
|
-
* it is the FILTER that produced these attributes, so comparing it is true by construction —
|
|
166
|
-
* an identity change is handled in `diff`, not here. `node` is out because it is a route.
|
|
167
|
-
*/
|
|
168
|
-
matches: () => true,
|
|
169
|
-
/** ⚠️ THE INDEX, NOT THE OBJECT. There is no GET on `{name}`; see the second ⛔ in the header. */
|
|
170
|
-
path: (props) => `nodes/${props.node}/ceph/fs`,
|
|
171
|
-
});
|
|
172
|
-
|
|
148
|
+
/**
|
|
149
|
+
* ⛔ NOTHING IS COMPARED, AND THAT IS THE MOST DELIBERATE LINE IN THIS FILE. A CephFS has no
|
|
150
|
+
* readable, writable field: the index returns only the name and the pools behind it, and every
|
|
151
|
+
* prop this resource accepts is create-time or delete-time. So "present" IS "settled", and the
|
|
152
|
+
* only honest comparison is none at all.
|
|
153
|
+
* ⚠️ THE TEMPTING ADDITION IS `pg_num`, AND IT WOULD BE THE WORST BUG THIS PACKAGE HAS SHIPPED.
|
|
154
|
+
* It is not in the index at all, so it can only be read from the pool endpoint — where the
|
|
155
|
+
* autoscaler owns it. MEASURED on C1 today: created at 128, live at 32, with the metadata
|
|
156
|
+
* pool already scheduled down to 16. Compared, that is an eternal mismatch — and since there
|
|
157
|
+
* is no update path, treating this as a diff would plan a REPLACE. The plan would destroy and
|
|
158
|
+
* rebuild a live filesystem on every deploy, triggered by a daemon on its own schedule.
|
|
159
|
+
* ⚠️ `data_pools` AND `metadata_pool_id` ARE OUT FOR THE ORDINARY REASONS: a set PVE returns
|
|
160
|
+
* in its own order, and an integer id for something written as a name. `name` is out because
|
|
161
|
+
* it is the FILTER that produced these attributes, so comparing it is true by construction —
|
|
162
|
+
* an identity change is handled in `diff`, not here. `node` is out because it is a route.
|
|
163
|
+
*/
|
|
173
164
|
export const ProxmoxCephFsProvider = () =>
|
|
174
165
|
Provider.effect(
|
|
175
166
|
ProxmoxCephFs,
|
|
@@ -182,67 +173,71 @@ export const ProxmoxCephFsProvider = () =>
|
|
|
182
173
|
*/
|
|
183
174
|
list: () => Effect.succeed([]),
|
|
184
175
|
read: Effect.fn(function* ({ olds }) {
|
|
185
|
-
return yield*
|
|
176
|
+
return yield* readFs(olds);
|
|
186
177
|
}),
|
|
187
178
|
/**
|
|
188
|
-
* ⛔ A CHANGED `name` IS A REPLACE, AND
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
179
|
+
* ⛔ A CHANGED `name` IS A REPLACE, AND NOTHING ELSE CAN SAY SO. Left undetected, a rename
|
|
180
|
+
* reads the NEW name, finds nothing, and plans the drift-shaped `update` this family uses
|
|
181
|
+
* for "Alchemy has state, the cluster does not" — so reconcile would build the new
|
|
182
|
+
* filesystem and LEAVE THE OLD ONE, with its pools, its storage entry and its bytes,
|
|
183
|
+
* owned by nobody and invisible in every later plan.
|
|
193
184
|
* ⛔ SAYING `replace` IS HONEST, AND IT IS ALSO WHAT MAKES A TYPO EXPENSIVE. Alchemy
|
|
194
185
|
* creates first and deletes second, so the old filesystem is destroyed AFTER the new
|
|
195
186
|
* one exists — with `remove-pools` unset the bytes are stranded rather than erased, and
|
|
196
187
|
* that default is the only thing between a mis-typed rename and an unrecoverable one.
|
|
197
|
-
*
|
|
198
|
-
* silent. ⚠️ `node` is NOT part of identity and must never join this comparison.
|
|
188
|
+
* ⚠️ `node` is NOT part of identity and must never join this comparison.
|
|
199
189
|
*/
|
|
200
190
|
diff: Effect.fn(function* ({ news, output }) {
|
|
201
|
-
if (
|
|
191
|
+
if (!isResolved(news)) return undefined;
|
|
192
|
+
yield* guardWrite(CEPH_FS_CREATE, createForm(news), output === undefined);
|
|
193
|
+
if (output !== undefined && news.name !== output.name) {
|
|
202
194
|
return { action: 'replace' } as const;
|
|
203
195
|
}
|
|
204
|
-
|
|
196
|
+
if (output === undefined) return undefined;
|
|
197
|
+
const live = yield* readFs(news);
|
|
198
|
+
if (live === undefined) {
|
|
199
|
+
yield* guardWrite(CEPH_FS_CREATE, createForm(news), true);
|
|
200
|
+
return { action: 'update' } as const;
|
|
201
|
+
}
|
|
202
|
+
// ⛔ SEE THE ⛔ ABOVE `ProxmoxCephFsProvider`: NOTHING ELSE IS COMPARED. Present is settled.
|
|
203
|
+
return { action: 'noop' } as const;
|
|
205
204
|
}),
|
|
206
205
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* ENDED, reading proves the filesystem EXISTS, and PVE has shipped tasks that end OK
|
|
211
|
-
* having done nothing.
|
|
206
|
+
* ⚠️ THE READ-BACK IS KEPT EVEN THOUGH `createFs` ALREADY WAITED OUT THE TASK. Waiting
|
|
207
|
+
* proves the task ENDED, reading proves the filesystem EXISTS, and PVE has shipped tasks
|
|
208
|
+
* that end OK having done nothing.
|
|
212
209
|
* ⚠️ AN EXISTING FILESYSTEM IS RETURNED UNTOUCHED. There is no update call to make, so the
|
|
213
210
|
* only write this handler can perform is a create; `diff` has already decided that a
|
|
214
211
|
* real change is a replace, and Alchemy calls delete for the old generation itself.
|
|
215
212
|
*/
|
|
216
213
|
reconcile: Effect.fn(function* ({ news }) {
|
|
217
|
-
const live = yield*
|
|
214
|
+
const live = yield* readFs(news);
|
|
218
215
|
if (live !== undefined) return live;
|
|
219
216
|
yield* createFs(news);
|
|
220
|
-
const after = yield*
|
|
217
|
+
const after = yield* readFs(news);
|
|
221
218
|
if (after === undefined) return yield* Effect.die(notCreated(news));
|
|
222
219
|
return after;
|
|
223
220
|
}),
|
|
224
221
|
/**
|
|
225
|
-
* ⛔
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* fail the destroy rather than letting Alchemy drop the state entry for a live object.
|
|
222
|
+
* ⛔ THE READ-BACK IS WHAT TURNS "the task said OK" INTO "it is actually gone": `destroyfs`
|
|
223
|
+
* refuses while a non-disabled `cephfs` storage still references the filesystem, and that
|
|
224
|
+
* refusal must fail the destroy rather than letting Alchemy drop the state entry for a
|
|
225
|
+
* live object. `destroyFs` itself stays on `client.ts` — ceph-fs-wire.ts's own header has
|
|
226
|
+
* the measured, protocol-level reason.
|
|
231
227
|
*/
|
|
232
228
|
delete: Effect.fn(function* ({ olds }) {
|
|
233
229
|
/**
|
|
234
230
|
* ⚠️ A FILESYSTEM SOMEBODY ALREADY REMOVED BY HAND IS NOT AN ERROR. `destroyfs` dies
|
|
235
231
|
* synchronously with "no such cephfs", which would fail every destroy of a stack whose
|
|
236
232
|
* filesystem was cleaned up outside Alchemy and leave the state entry unremovable
|
|
237
|
-
* without editing the store.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* 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.
|
|
242
237
|
*/
|
|
243
|
-
if ((yield*
|
|
238
|
+
if ((yield* readFs(olds)) === undefined) return;
|
|
244
239
|
yield* destroyFs(olds);
|
|
245
|
-
const after = yield*
|
|
240
|
+
const after = yield* readFs(olds);
|
|
246
241
|
if (after !== undefined) return yield* Effect.die(notDestroyed(olds));
|
|
247
242
|
}),
|
|
248
243
|
}),
|
|
@@ -56,9 +56,10 @@
|
|
|
56
56
|
* maintenance — the churn-that-reads-like-drift that keeps `digest` out of storage.ts.
|
|
57
57
|
*
|
|
58
58
|
*/
|
|
59
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
59
60
|
import * as Effect from 'effect/Effect';
|
|
60
61
|
import type { CephOsdProps } from './ceph-osd.ts';
|
|
61
|
-
import {
|
|
62
|
+
import { runPve } from './distilled-pve.ts';
|
|
62
63
|
import { bool, int, num, text } from './values.ts';
|
|
63
64
|
|
|
64
65
|
/** A CRUSH node. Buckets carry `children`; an OSD leaf carries `type: 'osd'`. */
|
|
@@ -181,13 +182,21 @@ export const osdAttributes = (leaf: CrushNode, props: CephOsdProps): CephOsdAttr
|
|
|
181
182
|
* ★ IT LIVES HERE RATHER THAN IN ceph-osd.ts BECAUSE THE READ SIDE IS THIS FILE'S WHOLE JOB —
|
|
182
183
|
* `findOsd` and `osdAttributes` are the two halves it composes, and keeping the caller beside
|
|
183
184
|
* them is what let ceph-osd.ts come back under the 250-line cap when create and delete were
|
|
184
|
-
* added.
|
|
185
|
-
*
|
|
185
|
+
* added.
|
|
186
|
+
*
|
|
187
|
+
* ★ MIGRATED OFF `client.ts`'s `pve()` ONTO `@distilled.cloud/proxmox`'s typed `nodes.
|
|
188
|
+
* getNodeCephOsd` (2026-09-24, decision 43, 2c) — the tree endpoint, confirmed against
|
|
189
|
+
* distilled's own schema to still answer `{flags?, root: unknown}`, matching the shape
|
|
190
|
+
* `osdLeaves` already walks. ⛔ STILL NO `Effect.orElseSucceed` — the ⛔ on `handlers.read` in
|
|
191
|
+
* ceph-osd.ts is unchanged and is still the reason: this path is a collection that always
|
|
192
|
+
* exists while Ceph is installed, so a failure here is never "the OSD is gone" and must
|
|
193
|
+
* propagate rather than fold. This is the one migrated Ceph family that does NOT match
|
|
194
|
+
* ceph-pool-wire.ts's/ceph-fs-distilled.ts's single-fold shape, because it never had it.
|
|
186
195
|
*/
|
|
187
196
|
export const readOsd = (props: CephOsdProps) =>
|
|
188
|
-
|
|
197
|
+
runPve(props.target, 'read', false, nodes.getNodeCephOsd({ node: props.node })).pipe(
|
|
189
198
|
Effect.map((t) => {
|
|
190
|
-
const leaf = t
|
|
199
|
+
const leaf = findOsd(t as unknown as CrushNode, props.osdid);
|
|
191
200
|
return leaf === undefined ? undefined : osdAttributes(leaf, props);
|
|
192
201
|
}),
|
|
193
202
|
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephOsd`'s create path, through Alchemy's own Plan and Apply over a fake cluster.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THIS NEVER RUNS ON THE REAL CLUSTER — ceph-osd.ts's own header has the measured reason
|
|
5
|
+
* (`createosd`/`destroyosd` carry no PVE permissions block, so any OpenBao-minted token 403s
|
|
6
|
+
* regardless of transport). The fake here enforces no such check, which is exactly why it is
|
|
7
|
+
* worth running: it is the only way to catch a wire-translation bug (a wrong field name, a
|
|
8
|
+
* dropped `device_class`) that a real deploy would never reach far enough to expose.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
13
|
+
import { ProxmoxCephOsd, ProxmoxCephOsdProvider } from './ceph-osd.ts';
|
|
14
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
15
|
+
|
|
16
|
+
const NODE = 'node-b';
|
|
17
|
+
const TREE = `nodes/${NODE}/ceph/osd`;
|
|
18
|
+
|
|
19
|
+
describe('a genuinely new OSD', () => {
|
|
20
|
+
test('POSTs dev and crush-device-class, and reads the new leaf back by id', async () => {
|
|
21
|
+
let created = false;
|
|
22
|
+
const fake = fakePve((call: PveCall) => {
|
|
23
|
+
if (call.method === 'GET' && call.path === TREE) {
|
|
24
|
+
const leaves = created
|
|
25
|
+
? [
|
|
26
|
+
{
|
|
27
|
+
ceph_version_short: '20.2.2',
|
|
28
|
+
device_class: 'ssd',
|
|
29
|
+
host: NODE,
|
|
30
|
+
id: '9',
|
|
31
|
+
name: 'osd.9',
|
|
32
|
+
status: 'up',
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
: [];
|
|
36
|
+
return { flags: '', root: { children: leaves.map((l) => ({ ...l, type: 'osd' })) } };
|
|
37
|
+
}
|
|
38
|
+
if (call.method === 'POST' && call.path === TREE) {
|
|
39
|
+
created = true;
|
|
40
|
+
return `UPID:${NODE}:fake:createosd`;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
});
|
|
44
|
+
await withoutBao(async () => {
|
|
45
|
+
const engine = engineOver(ProxmoxCephOsdProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
46
|
+
const declared = () =>
|
|
47
|
+
ProxmoxCephOsd('node-b-osd9', {
|
|
48
|
+
dev: '/dev/nvme2n1',
|
|
49
|
+
device_class: 'ssd',
|
|
50
|
+
node: NODE,
|
|
51
|
+
osdid: 9,
|
|
52
|
+
target: FAKE_TARGET,
|
|
53
|
+
});
|
|
54
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-osd9': 'create' });
|
|
55
|
+
});
|
|
56
|
+
const post = fake.calls.find((c) => c.method === 'POST');
|
|
57
|
+
expect(post?.form['dev']).toBe('/dev/nvme2n1');
|
|
58
|
+
expect(post?.form['crush-device-class']).toBe('ssd');
|
|
59
|
+
expect(fake.writes()).toEqual([`POST ${TREE}`]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* `defaultRemovalPolicy: 'retain'`, so removing a declaration does NOT reach `destroyOsd` — a
|
|
12
12
|
* caller opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
13
13
|
*/
|
|
14
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
14
15
|
import * as Effect from 'effect/Effect';
|
|
15
16
|
import { readOsd } from './ceph-osd-tree.ts';
|
|
16
17
|
import type { CephOsdProps } from './ceph-osd.ts';
|
|
17
|
-
import { pve } from './client.ts';
|
|
18
18
|
import { guardForm } from './constraint-guard.ts';
|
|
19
19
|
import type { EndpointKey } from './constraints.ts';
|
|
20
|
+
import { runPve } from './distilled-pve.ts';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The vendor rules the create form is checked against at plan time.
|
|
@@ -59,7 +60,16 @@ export const createOsd = Effect.fn(function* (news: CephOsdProps) {
|
|
|
59
60
|
// ⛔ BEFORE THE POST, BECAUSE THE POST ZAPS A BLOCK DEVICE. A vendor rule broken here is worth
|
|
60
61
|
// refusing at plan rather than discovering from a 400 after `ceph-volume` has run.
|
|
61
62
|
yield* guardForm(OSD_CREATE_ENDPOINT, form, true);
|
|
62
|
-
yield*
|
|
63
|
+
yield* runPve(
|
|
64
|
+
news.target,
|
|
65
|
+
'provision',
|
|
66
|
+
true,
|
|
67
|
+
nodes.createNodeCephOsd({
|
|
68
|
+
dev: news.dev,
|
|
69
|
+
node: news.node,
|
|
70
|
+
...(news.device_class === undefined ? {} : { crush_device_class: news.device_class }),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
63
73
|
const created = yield* readOsd(news);
|
|
64
74
|
if (created === undefined) {
|
|
65
75
|
return yield* Effect.die(
|
|
@@ -91,12 +101,29 @@ export const createOsd = Effect.fn(function* (news: CephOsdProps) {
|
|
|
91
101
|
*
|
|
92
102
|
* ⚠️ `cleanup` DEFAULTS TO FALSE, so the logical volumes survive and the disk can be re-added
|
|
93
103
|
* without a rebuild. Ceph still starts backfilling the missing copies the moment the OSD goes.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ `cleanup` RIDES A DELETE BODY, ON `client.ts` AND ON DISTILLED ALIKE — PRE-EXISTING, NOT
|
|
106
|
+
* INTRODUCED HERE. `client.ts`'s own `buildRequest` (client.ts) puts `form` in the body for
|
|
107
|
+
* every method, DELETE included, and CHECKED against distilled's generated
|
|
108
|
+
* `DeleteNodeCephOsdRequest`: `cleanup` carries no `T.Query()` annotation either, so it also
|
|
109
|
+
* defaults to a body field. ceph-fs-wire.ts's own ⛔ (measured from PVE's `AnyEvent.pm`) is that
|
|
110
|
+
* PVE's server never reads a body on DELETE — so `cleanup=1` was ALREADY silently ignored by
|
|
111
|
+
* the live cluster before this migration, on the unmigrated code too. Migrating carries the
|
|
112
|
+
* SAME behaviour forward exactly, byte-for-byte, rather than introducing a new gap; unlike
|
|
113
|
+
* `Proxmox.CephFs`'s delete, this is not a reason to keep the call on the hand client. It has
|
|
114
|
+
* zero practical effect today regardless, since the whole DELETE 403s for this package's
|
|
115
|
+
* credential either way (the root-only ⛔ at the top of ceph-osd.ts) — flagged separately as a
|
|
116
|
+
* pre-existing, out-of-scope bug worth its own fix (move `cleanup` into the query string, the
|
|
117
|
+
* way `destroyPath` in ceph-fs-wire.ts already does).
|
|
94
118
|
*/
|
|
95
119
|
export const destroyOsd = (olds: CephOsdProps) =>
|
|
96
|
-
|
|
120
|
+
runPve(
|
|
97
121
|
olds.target,
|
|
98
122
|
'provision',
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
true,
|
|
124
|
+
nodes.deleteNodeCephOsd({
|
|
125
|
+
node: olds.node,
|
|
126
|
+
osdid: String(olds.osdid),
|
|
127
|
+
...(olds.cleanup === true ? { cleanup: '1' } : {}),
|
|
128
|
+
}),
|
|
102
129
|
);
|
package/src/proxmox/ceph-osd.ts
CHANGED
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
* `hf-provision@pve!…` token would likely be answered 403 however wide its role. That is a
|
|
34
34
|
* reason to leave the write lane closed, not something to confirm by trying: if the reasoning
|
|
35
35
|
* is wrong, the confirmation costs a disk.
|
|
36
|
+
*
|
|
37
|
+
* ★ MIGRATED OFF `client.ts` ONTO `@distilled.cloud/proxmox` (2026-09-24, decision 43, 2c) — this
|
|
38
|
+
* file itself never called `pve()` directly, so nothing here changed; ceph-osd-tree.ts's own
|
|
39
|
+
* header has the read side (still no `Effect.orElseSucceed`, unlike every other migrated Ceph
|
|
40
|
+
* family), ceph-osd-write.ts's has the write side and the one pre-existing, out-of-scope gap
|
|
41
|
+
* this migration carries forward rather than fixes.
|
|
36
42
|
*/
|
|
37
43
|
import { Resource } from 'alchemy';
|
|
38
44
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -6,33 +6,40 @@
|
|
|
6
6
|
* synchronous. Ceph's are not — a pool delete returns before the PGs are gone — so this file is
|
|
7
7
|
* the polling that difference forces, and ceph-pool.ts is the declaration.
|
|
8
8
|
*/
|
|
9
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
9
10
|
import * as Effect from 'effect/Effect';
|
|
10
11
|
import type { CephPoolAttributes, CephPoolProps } from './ceph-pool-form.ts';
|
|
11
|
-
import {
|
|
12
|
+
import { runPve } from './distilled-pve.ts';
|
|
12
13
|
import type { PveRequirements } from './resource.ts';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* ⚠️ THE DEPENDENCIES ARE PASSED IN RATHER THAN IMPORTED, and that is what made this file
|
|
16
|
-
* separable at all. `
|
|
17
|
-
* ceph-pool.ts; importing
|
|
18
|
-
* definitions of one path. Passing
|
|
17
|
+
* separable at all. `ops.read` — now `readPoolStatus` (ceph-pool-wire.ts) — is a module-level
|
|
18
|
+
* binding in ceph-pool.ts; importing it back would be a runtime cycle, and duplicating it would
|
|
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`.
|
|
19
21
|
*/
|
|
20
|
-
export type PoolRead = (
|
|
22
|
+
export type PoolRead<E> = (
|
|
21
23
|
props: CephPoolProps,
|
|
22
|
-
) => Effect.Effect<CephPoolAttributes | undefined,
|
|
24
|
+
) => Effect.Effect<CephPoolAttributes | undefined, E, PveRequirements>;
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* ⛔ THE GUARD ON THE CREATE.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* An empty list is trusted: the collection
|
|
30
|
-
* 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.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ `describe` IS STILL A PLAIN STRING (ceph-pool.ts builds it from `object`/`collection` in
|
|
35
|
+
* ceph-pool-form.ts) — this function no longer takes a `collection` PATH, since
|
|
36
|
+
* `nodes.listNodeCephPool` addresses the endpoint itself; the parameter list keeps `describe`
|
|
37
|
+
* only, to name the object in the refusal message.
|
|
31
38
|
*/
|
|
32
|
-
export const confirmAbsent = (props: CephPoolProps,
|
|
33
|
-
|
|
39
|
+
export const confirmAbsent = (props: CephPoolProps, describe: string) =>
|
|
40
|
+
runPve(props.target, 'read', false, nodes.listNodeCephPool({ node: props.node })).pipe(
|
|
34
41
|
Effect.flatMap((rows) =>
|
|
35
|
-
|
|
42
|
+
rows.some((row) => row.pool_name === props.name)
|
|
36
43
|
? Effect.die(
|
|
37
44
|
new Error(
|
|
38
45
|
`${describe}: the cluster lists this pool but its status could not be read, ` +
|
|
@@ -55,9 +62,9 @@ export const confirmAbsent = (props: CephPoolProps, collection: string, describe
|
|
|
55
62
|
* ⚠️ SHORT POLLS, LOW CAP: 2s apart, 30 tries, about a minute. A pool create takes seconds; one
|
|
56
63
|
* still unsettled after a minute is a fault to surface, not a wait to lengthen.
|
|
57
64
|
*/
|
|
58
|
-
export const settle = (
|
|
65
|
+
export const settle = <E>(
|
|
59
66
|
props: CephPoolProps,
|
|
60
|
-
read: PoolRead
|
|
67
|
+
read: PoolRead<E>,
|
|
61
68
|
done: (live: CephPoolAttributes | undefined) => boolean,
|
|
62
69
|
) =>
|
|
63
70
|
Effect.gen(function* () {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephPool`'s distilled wire lane: the status read, and the two request translations.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF ceph-pool-form.ts FOR THE 250-LINE CAP (that file is already near it) — the seam
|
|
5
|
+
* is "touches the cluster or not", the same one ceph-pool-form.ts itself draws against
|
|
6
|
+
* ceph-pool.ts. Nothing in ceph-pool-form.ts changed: `createBody`/`updateBody` still build the
|
|
7
|
+
* PVE form, unaware distilled exists: this file is the ONLY place that translates one into a
|
|
8
|
+
* typed distilled request.
|
|
9
|
+
*
|
|
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).
|
|
17
|
+
*/
|
|
18
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { runPve } from './distilled-pve.ts';
|
|
21
|
+
import {
|
|
22
|
+
type CephPoolAttributes,
|
|
23
|
+
type CephPoolProps,
|
|
24
|
+
UNSET,
|
|
25
|
+
applications,
|
|
26
|
+
createBody,
|
|
27
|
+
updateBody,
|
|
28
|
+
} from './ceph-pool-form.ts';
|
|
29
|
+
import { bool, int, num, text } from './values.ts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One status answer as attributes, or `undefined` for the index-trap shape — the same defensive
|
|
33
|
+
* check ceph-pool.ts's pre-migration `attributes` carried, kept even though a well-formed
|
|
34
|
+
* distilled decode should never hand this function anything else: `GetNodeCephPoolStatusResponse`
|
|
35
|
+
* declares `name` required, so a response shaped like the INDEX (an array) fails the Schema decode
|
|
36
|
+
* before this runs at all. Left in as the same fail-safe rather than trusting that alone.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ `live` IS CAST PAST `GetNodeCephPoolStatusResponse`'s OWN TS TYPE, the same measured reason
|
|
39
|
+
* node-network-wire.ts's `attributesOf` casts past `GetNodeNetworkResponse`: Effect Schema's
|
|
40
|
+
* structural (non-exact) decode does not strip fields the declared type omits, and the live
|
|
41
|
+
* probe below (2026-09-24) showed extra fields (`statistics`, `autoscale_status`) surviving.
|
|
42
|
+
*/
|
|
43
|
+
const attributesOf = (live: unknown, props: CephPoolProps): CephPoolAttributes | undefined => {
|
|
44
|
+
const row = live as Record<string, unknown>;
|
|
45
|
+
if (typeof row['name'] !== 'string') return undefined;
|
|
46
|
+
return {
|
|
47
|
+
applications: applications(row['application_list']),
|
|
48
|
+
crush_rule: text(row['crush_rule']),
|
|
49
|
+
id: int(row['id'], UNSET),
|
|
50
|
+
min_size: int(row['min_size'], UNSET),
|
|
51
|
+
name: props.name,
|
|
52
|
+
node: props.node,
|
|
53
|
+
nodelete: bool(row['nodelete']),
|
|
54
|
+
nopgchange: bool(row['nopgchange']),
|
|
55
|
+
nosizechange: bool(row['nosizechange']),
|
|
56
|
+
pg_autoscale_mode: text(row['pg_autoscale_mode']),
|
|
57
|
+
pg_num: int(row['pg_num'], UNSET),
|
|
58
|
+
pg_num_min: int(row['pg_num_min'], UNSET),
|
|
59
|
+
size: int(row['size'], UNSET),
|
|
60
|
+
target_size: int(row['target_size'], UNSET),
|
|
61
|
+
target_size_ratio: num(row['target_size_ratio'], UNSET),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
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.
|
|
69
|
+
*
|
|
70
|
+
* ⛔ `verbose=1` IS WHAT MAKES `applications` VISIBLE AT ALL — ceph-pool.ts's own header has the
|
|
71
|
+
* measured reason and the cost (two extra, unguarded mon commands PVE runs to answer it).
|
|
72
|
+
*/
|
|
73
|
+
export const readPoolStatus = (props: CephPoolProps) =>
|
|
74
|
+
runPve(
|
|
75
|
+
props.target,
|
|
76
|
+
'read',
|
|
77
|
+
false,
|
|
78
|
+
nodes.getNodeCephPoolStatus({ name: props.name, node: props.node, verbose: '1' }),
|
|
79
|
+
).pipe(
|
|
80
|
+
Effect.map((live) => attributesOf(live, props)),
|
|
81
|
+
Effect.catchTag('CephPoolNotFound', () => Effect.succeed(undefined)),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/** The two endpoints, as the keys `guardWrite` (distilled-guard.ts) checks both forms against. */
|
|
85
|
+
export const CEPH_POOL_CREATE = 'pve:POST /nodes/{node}/ceph/pool';
|
|
86
|
+
export const CEPH_POOL_UPDATE = 'pve:PUT /nodes/{node}/ceph/pool/{name}';
|
|
87
|
+
|
|
88
|
+
/** The actual `createNodeCephPool` call body — `createBody`, translated. No field is renamed. */
|
|
89
|
+
export const toDistilledCreate = (props: CephPoolProps): nodes.CreateNodeCephPoolRequest =>
|
|
90
|
+
({
|
|
91
|
+
...createBody(props),
|
|
92
|
+
node: props.node,
|
|
93
|
+
}) as unknown as nodes.CreateNodeCephPoolRequest;
|
|
94
|
+
|
|
95
|
+
/** The actual `putNodeCephPool` call body — `updateBody`, translated. No field is renamed. */
|
|
96
|
+
export const toDistilledUpdate = (props: CephPoolProps): nodes.PutNodeCephPoolRequest =>
|
|
97
|
+
({
|
|
98
|
+
...updateBody(props),
|
|
99
|
+
name: props.name,
|
|
100
|
+
node: props.node,
|
|
101
|
+
}) as unknown as nodes.PutNodeCephPoolRequest;
|