@homeflare/alchemy 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +14 -0
  2. package/dist/argocd/cluster.d.ts +102 -0
  3. package/dist/argocd/cluster.d.ts.map +1 -0
  4. package/dist/argocd/credentials.d.ts +37 -0
  5. package/dist/argocd/credentials.d.ts.map +1 -0
  6. package/dist/argocd/fake-argocd.d.ts +42 -0
  7. package/dist/argocd/fake-argocd.d.ts.map +1 -0
  8. package/dist/argocd/git-credentials.d.ts +29 -0
  9. package/dist/argocd/git-credentials.d.ts.map +1 -0
  10. package/dist/argocd/index.d.ts +19 -0
  11. package/dist/argocd/index.d.ts.map +1 -0
  12. package/dist/argocd/index.js +306 -0
  13. package/dist/argocd/index.js.map +17 -0
  14. package/dist/argocd/providers.d.ts +13 -0
  15. package/dist/argocd/providers.d.ts.map +1 -0
  16. package/dist/argocd/repo-creds.d.ts +67 -0
  17. package/dist/argocd/repo-creds.d.ts.map +1 -0
  18. package/dist/argocd/repository.d.ts +87 -0
  19. package/dist/argocd/repository.d.ts.map +1 -0
  20. package/dist/argocd/resource.d.ts +90 -0
  21. package/dist/argocd/resource.d.ts.map +1 -0
  22. package/dist/cloudflare/index.js +4 -4
  23. package/dist/cloudflare/index.js.map +3 -3
  24. package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
  25. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  26. package/dist/litellm/index.js.map +2 -2
  27. package/dist/litellm/operations.d.ts.map +1 -1
  28. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  29. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  30. package/dist/proxmox/ceph-daemon-wire.d.ts +33 -0
  31. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  32. package/dist/proxmox/ceph-daemon.d.ts +35 -0
  33. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  34. package/dist/proxmox/ceph-fs-distilled.d.ts +14 -0
  35. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  36. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  37. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  38. package/dist/proxmox/ceph-fs.d.ts +31 -6
  39. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  40. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  41. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  43. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-osd.d.ts +6 -0
  45. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  46. package/dist/proxmox/ceph-pool-settle.d.ts +10 -4
  47. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-pool-wire.d.ts +46 -0
  49. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-pool.d.ts +10 -5
  51. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  52. package/dist/proxmox/group-wire.d.ts +1 -1
  53. package/dist/proxmox/group-wire.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +611 -477
  55. package/dist/proxmox/index.js.map +15 -12
  56. package/dist/proxmox/storage-wire.d.ts +8 -1
  57. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  58. package/dist/proxmox/user-wire.d.ts +1 -1
  59. package/dist/proxmox/user-wire.d.ts.map +1 -1
  60. package/docs/argocd-kubernetes.md +100 -0
  61. package/docs/argocd.md +139 -0
  62. package/docs/credentials.md +9 -0
  63. package/docs/litellm.md +17 -0
  64. package/docs/peers.md +7 -0
  65. package/docs/unifi.md +45 -0
  66. package/package.json +8 -3
  67. package/src/argocd/cluster.test.ts +120 -0
  68. package/src/argocd/cluster.ts +222 -0
  69. package/src/argocd/credentials.ts +38 -0
  70. package/src/argocd/fake-argocd.ts +60 -0
  71. package/src/argocd/git-credentials.ts +71 -0
  72. package/src/argocd/index.ts +40 -0
  73. package/src/argocd/providers.ts +17 -0
  74. package/src/argocd/repo-creds.test.ts +123 -0
  75. package/src/argocd/repo-creds.ts +115 -0
  76. package/src/argocd/repository.test.ts +167 -0
  77. package/src/argocd/repository.ts +165 -0
  78. package/src/argocd/resource.ts +150 -0
  79. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  80. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  81. package/src/litellm/operations.ts +8 -4
  82. package/src/proxmox/ceph-daemon-form.ts +7 -8
  83. package/src/proxmox/ceph-daemon-wire.ts +115 -0
  84. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  85. package/src/proxmox/ceph-daemon.ts +65 -47
  86. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  87. package/src/proxmox/ceph-fs-distilled.ts +102 -0
  88. package/src/proxmox/ceph-fs-wire.ts +17 -31
  89. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  90. package/src/proxmox/ceph-fs.ts +66 -69
  91. package/src/proxmox/ceph-osd-tree.ts +14 -5
  92. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  93. package/src/proxmox/ceph-osd-write.ts +33 -6
  94. package/src/proxmox/ceph-osd.ts +6 -0
  95. package/src/proxmox/ceph-pool-settle.ts +13 -7
  96. package/src/proxmox/ceph-pool-wire.ts +110 -0
  97. package/src/proxmox/ceph-pool-write.test.ts +145 -0
  98. package/src/proxmox/ceph-pool.ts +87 -98
  99. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -19,12 +19,21 @@
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 `delete` AND `reconcile` ARE HAND-WRITTEN BELOW rather than taken from
23
- * `pveHandlers`. `pveOperations.destroy` deletes `spec.path`, which here is the index:
24
- * `DELETE /nodes/{node}/ceph/fs` is not a route, so a factory delete would fail every destroy
25
- * while the filesystem stayed exactly where it was. `reconcile` is hand-written for a second,
26
- * independent reason — the forked worker — set out in ceph-fs-wire.ts. acl.ts is the
27
- * precedent for a family that genuinely does not fit; this is the second.
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
+ * distilled types the delete's `remove-pools`/`remove-storages` flags as a request BODY, and
33
+ * this file's own next ⛔ (unchanged from before this migration) is the measured reason PVE
34
+ * would silently ignore both on a DELETE sent that way — ceph-fs-wire.ts's header has the fuller
35
+ * evidence. `readFs`/`diff`/`reconcile` keep the SAME single-fold, present-is-settled shape this
36
+ * family always had; see ceph-pool-wire.ts's own header for why that is not a bug left unfixed.
28
37
  *
29
38
  * ⛔ CREATING A CephFS CREATES TWO CEPH POOLS, AND A `Proxmox.CephPool` MUST NOT ALSO DECLARE THEM.
30
39
  * `createfs` builds `<name>_data` and `<name>_metadata` itself and refuses outright if either
@@ -59,16 +68,10 @@ import { Resource } from 'alchemy';
59
68
  import { isResolved } from 'alchemy/Diff';
60
69
  import * as Provider from 'alchemy/Provider';
61
70
  import * as Effect from 'effect/Effect';
62
- import {
63
- createForm,
64
- createFs,
65
- destroyFs,
66
- notCreated,
67
- notDestroyed,
68
- objectPath,
69
- readRow,
70
- } from './ceph-fs-wire.ts';
71
- import { type PveRequirements, type WithTarget, pveOperations } from './resource.ts';
71
+ import { createFs, readFs } from './ceph-fs-distilled.ts';
72
+ import { CEPH_FS_CREATE, createForm, destroyFs, notCreated, notDestroyed } from './ceph-fs-wire.ts';
73
+ import { guardWrite } from './distilled-guard.ts';
74
+ import { type PveRequirements, type WithTarget } from './resource.ts';
72
75
 
73
76
  export interface CephFsProps extends WithTarget {
74
77
  /** ⚠️ Which node answers the call, NOT which node holds the filesystem. See the header. */
@@ -143,33 +146,22 @@ export const ProxmoxCephFs = Resource<ProxmoxCephFs>('Proxmox.CephFs', {
143
146
  defaultRemovalPolicy: 'retain',
144
147
  });
145
148
 
146
- const ops = pveOperations<CephFsProps, CephFsAttributes>({
147
- attributes: readRow,
148
- /** ⚠️ The `{name}` path — longer than `path()`, deliberately. See the second ⛔ in the header. */
149
- collection: objectPath,
150
- createForm,
151
- endpoint: { create: 'pve:POST /nodes/{node}/ceph/fs/{name}' }, // ⚠️ POSTed on the object.
152
- /**
153
- * ⛔ NOTHING IS COMPARED, AND THAT IS THE MOST DELIBERATE LINE IN THIS FILE. A CephFS has no
154
- * readable, writable field: the index returns only the name and the pools behind it, and every
155
- * prop this resource accepts is create-time or delete-time. So "present" IS "settled", and the
156
- * only honest comparison is none at all.
157
- * ⚠️ THE TEMPTING ADDITION IS `pg_num`, AND IT WOULD BE THE WORST BUG THIS PACKAGE HAS SHIPPED.
158
- * It is not in the index at all, so it can only be read from the pool endpoint — where the
159
- * autoscaler owns it. MEASURED on C1 today: created at 128, live at 32, with the metadata
160
- * pool already scheduled down to 16. Compared, that is an eternal mismatch; and because
161
- * `updateForm` is undefined, the action it produces is REPLACE. The plan would destroy and
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
-
149
+ /**
150
+ * ⛔ NOTHING IS COMPARED, AND THAT IS THE MOST DELIBERATE LINE IN THIS FILE. A CephFS has no
151
+ * readable, writable field: the index returns only the name and the pools behind it, and every
152
+ * prop this resource accepts is create-time or delete-time. So "present" IS "settled", and the
153
+ * only honest comparison is none at all.
154
+ * ⚠️ THE TEMPTING ADDITION IS `pg_num`, AND IT WOULD BE THE WORST BUG THIS PACKAGE HAS SHIPPED.
155
+ * It is not in the index at all, so it can only be read from the pool endpoint — where the
156
+ * autoscaler owns it. MEASURED on C1 today: created at 128, live at 32, with the metadata
157
+ * pool already scheduled down to 16. Compared, that is an eternal mismatch — and since there
158
+ * is no update path, treating this as a diff would plan a REPLACE. The plan would destroy and
159
+ * rebuild a live filesystem on every deploy, triggered by a daemon on its own schedule.
160
+ * ⚠️ `data_pools` AND `metadata_pool_id` ARE OUT FOR THE ORDINARY REASONS: a set PVE returns
161
+ * in its own order, and an integer id for something written as a name. `name` is out because
162
+ * it is the FILTER that produced these attributes, so comparing it is true by construction —
163
+ * an identity change is handled in `diff`, not here. `node` is out because it is a route.
164
+ */
173
165
  export const ProxmoxCephFsProvider = () =>
174
166
  Provider.effect(
175
167
  ProxmoxCephFs,
@@ -182,67 +174,72 @@ export const ProxmoxCephFsProvider = () =>
182
174
  */
183
175
  list: () => Effect.succeed([]),
184
176
  read: Effect.fn(function* ({ olds }) {
185
- return yield* ops.read(olds);
177
+ return yield* readFs(olds);
186
178
  }),
187
179
  /**
188
- * ⛔ A CHANGED `name` IS A REPLACE, AND THE FACTORY CANNOT SAY SO ALONE. Left to delegate,
189
- * a rename reads the NEW name, finds nothing, and returns the drift-shaped `update` that
190
- * `pveOperations.diff` uses for "Alchemy has state, the cluster does not" — so reconcile
191
- * would build the new filesystem and LEAVE THE OLD ONE, with its pools, its storage entry
192
- * and its bytes, owned by nobody and invisible in every later plan.
180
+ * ⛔ A CHANGED `name` IS A REPLACE, AND NOTHING ELSE CAN SAY SO. Left undetected, a rename
181
+ * reads the NEW name, finds nothing, and plans the drift-shaped `update` this family uses
182
+ * for "Alchemy has state, the cluster does not" — so reconcile would build the new
183
+ * filesystem and LEAVE THE OLD ONE, with its pools, its storage entry and its bytes,
184
+ * owned by nobody and invisible in every later plan.
193
185
  * ⛔ SAYING `replace` IS HONEST, AND IT IS ALSO WHAT MAKES A TYPO EXPENSIVE. Alchemy
194
186
  * creates first and deletes second, so the old filesystem is destroyed AFTER the new
195
187
  * one exists — with `remove-pools` unset the bytes are stranded rather than erased, and
196
188
  * that default is the only thing between a mis-typed rename and an unrecoverable one.
197
- * Do not "simplify" this back to the factory: the alternative is not safer, it is
198
- * silent. ⚠️ `node` is NOT part of identity and must never join this comparison.
189
+ * ⚠️ `node` is NOT part of identity and must never join this comparison.
199
190
  */
200
191
  diff: Effect.fn(function* ({ news, output }) {
201
- if (output !== undefined && isResolved(news) && news.name !== output.name) {
192
+ if (!isResolved(news)) return undefined;
193
+ yield* guardWrite(CEPH_FS_CREATE, createForm(news), output === undefined);
194
+ if (output !== undefined && news.name !== output.name) {
202
195
  return { action: 'replace' } as const;
203
196
  }
204
- return yield* ops.diff(news, output);
197
+ if (output === undefined) return undefined;
198
+ const live = yield* readFs(news);
199
+ if (live === undefined) {
200
+ yield* guardWrite(CEPH_FS_CREATE, createForm(news), true);
201
+ return { action: 'update' } as const;
202
+ }
203
+ // ⛔ SEE THE ⛔ ABOVE `ProxmoxCephFsProvider`: NOTHING ELSE IS COMPARED. Present is settled.
204
+ return { action: 'noop' } as const;
205
205
  }),
206
206
  /**
207
- * ⛔ NOT `ops.reconcile`, BECAUSE ITS READ-BACK RACES A FORKED WORKER. The POST returns a
208
- * UPID and nothing else; `createFs` waits the task out and surfaces its real error. The
209
- * read-back the factory does is kept here rather than dropped — waiting proves the task
210
- * ENDED, reading proves the filesystem EXISTS, and PVE has shipped tasks that end OK
211
- * having done nothing.
207
+ * ⚠️ THE READ-BACK IS KEPT EVEN THOUGH `createFs` ALREADY WAITED OUT THE TASK. Waiting
208
+ * proves the task ENDED, reading proves the filesystem EXISTS, and PVE has shipped tasks
209
+ * that end OK having done nothing.
212
210
  * ⚠️ AN EXISTING FILESYSTEM IS RETURNED UNTOUCHED. There is no update call to make, so the
213
211
  * only write this handler can perform is a create; `diff` has already decided that a
214
212
  * real change is a replace, and Alchemy calls delete for the old generation itself.
215
213
  */
216
214
  reconcile: Effect.fn(function* ({ news }) {
217
- const live = yield* ops.read(news);
215
+ const live = yield* readFs(news);
218
216
  if (live !== undefined) return live;
219
217
  yield* createFs(news);
220
- const after = yield* ops.read(news);
218
+ const after = yield* readFs(news);
221
219
  if (after === undefined) return yield* Effect.die(notCreated(news));
222
220
  return after;
223
221
  }),
224
222
  /**
225
- * ⛔ NOT `ops.destroy`, AND NOT AN OVERSIGHT. It would DELETE `spec.path`, which on this
226
- * family is the index — a route PVE does not implement — so every destroy would fail
227
- * while the filesystem stayed exactly where it was. The read-back afterwards is what
228
- * turns "the task said OK" into "it is actually gone": `destroyfs` refuses while a
229
- * non-disabled `cephfs` storage still references the filesystem, and that refusal must
230
- * fail the destroy rather than letting Alchemy drop the state entry for a live object.
223
+ * ⛔ THE READ-BACK IS WHAT TURNS "the task said OK" INTO "it is actually gone": `destroyfs`
224
+ * refuses while a non-disabled `cephfs` storage still references the filesystem, and that
225
+ * refusal must fail the destroy rather than letting Alchemy drop the state entry for a
226
+ * live object. `destroyFs` itself stays on `client.ts` — ceph-fs-wire.ts's own header has
227
+ * the measured, protocol-level reason.
231
228
  */
232
229
  delete: Effect.fn(function* ({ olds }) {
233
230
  /**
234
231
  * ⚠️ A FILESYSTEM SOMEBODY ALREADY REMOVED BY HAND IS NOT AN ERROR. `destroyfs` dies
235
232
  * synchronously with "no such cephfs", which would fail every destroy of a stack whose
236
233
  * filesystem was cleaned up outside Alchemy and leave the state entry unremovable
237
- * without editing the store. ⚠️ THE COST IS STATED PLAINLY: `ops.read` folds a FAILED
234
+ * without editing the store. ⚠️ THE COST IS STATED PLAINLY: `readFs` folds a FAILED
238
235
  * read into "absent" (the storage.ts trap), so a read this credential cannot perform
239
236
  * turns this into a destroy that reports success having done nothing. That is not a
240
237
  * new hazard — a broken read defeats the read-back below in exactly the same way — but
241
238
  * it is the reason a 403 on this family must be fixed rather than lived with.
242
239
  */
243
- if ((yield* ops.read(olds)) === undefined) return;
240
+ if ((yield* readFs(olds)) === undefined) return;
244
241
  yield* destroyFs(olds);
245
- const after = yield* ops.read(olds);
242
+ const after = yield* readFs(olds);
246
243
  if (after !== undefined) return yield* Effect.die(notDestroyed(olds));
247
244
  }),
248
245
  }),
@@ -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 { pve } from './client.ts';
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. ⚠️ A 404 or a 403 both arrive here as `undefined`: see the ⛔ on `readRole` in
185
- * resource.ts for why that distinction is invisible and what it costs.
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
- pve<Record<string, unknown>>(props.target, 'read', 'GET', `nodes/${props.node}/ceph/osd`).pipe(
197
+ runPve(props.target, 'read', false, nodes.getNodeCephOsd({ node: props.node })).pipe(
189
198
  Effect.map((t) => {
190
- const leaf = t === undefined ? undefined : findOsd(t, props.osdid);
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* pve(news.target, 'provision', 'POST', `nodes/${news.node}/ceph/osd`, form);
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
- pve(
120
+ runPve(
97
121
  olds.target,
98
122
  'provision',
99
- 'DELETE',
100
- `nodes/${olds.node}/ceph/osd/${String(olds.osdid)}`,
101
- olds.cleanup === true ? { cleanup: '1' } : {},
123
+ true,
124
+ nodes.deleteNodeCephOsd({
125
+ node: olds.node,
126
+ osdid: String(olds.osdid),
127
+ ...(olds.cleanup === true ? { cleanup: '1' } : {}),
128
+ }),
102
129
  );
@@ -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,16 +6,17 @@
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 { pve } from './client.ts';
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. `collection`, `object` and `ops.read` are module-level bindings in
17
- * ceph-pool.ts; importing them back would be a runtime cycle, and duplicating them would be two
18
- * definitions of one path. Passing them is the pattern zfs-pool-write.ts already uses.
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.
19
20
  */
20
21
  export type PoolRead = (
21
22
  props: CephPoolProps,
@@ -28,11 +29,16 @@ export type PoolRead = (
28
29
  * rather than folded, so "I could not ask" fails the deploy instead of creating over live data.
29
30
  * An empty list is trusted: the collection 403s when the role is too narrow rather than
30
31
  * filtering, so `[]` really does mean a cluster with no pools.
32
+ *
33
+ * ⚠️ `describe` IS STILL A PLAIN STRING (ceph-pool.ts builds it from `object`/`collection` in
34
+ * ceph-pool-form.ts) — this function no longer takes a `collection` PATH, since
35
+ * `nodes.listNodeCephPool` addresses the endpoint itself; the parameter list keeps `describe`
36
+ * only, to name the object in the refusal message.
31
37
  */
32
- export const confirmAbsent = (props: CephPoolProps, collection: string, describe: string) =>
33
- pve<{ pool_name?: string }[]>(props.target, 'read', 'GET', collection).pipe(
38
+ export const confirmAbsent = (props: CephPoolProps, describe: string) =>
39
+ runPve(props.target, 'read', false, nodes.listNodeCephPool({ node: props.node })).pipe(
34
40
  Effect.flatMap((rows) =>
35
- (rows ?? []).some((row) => row.pool_name === props.name)
41
+ rows.some((row) => row.pool_name === props.name)
36
42
  ? Effect.die(
37
43
  new Error(
38
44
  `${describe}: the cluster lists this pool but its status could not be read, ` +
@@ -0,0 +1,110 @@
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
+ * ⛔ ONE READ FUNCTION, NOT THE readXOrFail/foldingX PAIR user.ts/group.ts/storage.ts/zfs-pool.ts/
11
+ * node-network-wire.ts CARRY. THIS IS A DELIBERATE, MEASURED DIFFERENCE, NOT AN OVERSIGHT. Those
12
+ * families were BUILT with (or later given, after the NodeNetwork adversarial review) the
13
+ * dual-path pattern because their `diff`/`read` hooks needed to tell "genuinely absent" apart
14
+ * from "a transient failure" at specific call sites. CephPool's PRE-MIGRATION behaviour never
15
+ * had that distinction: `resource.ts`'s generic `pveOperations.read` — what `ops.read`/`ops.diff`
16
+ * called before this PR — folds EVERY failure into `undefined` unconditionally
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.
26
+ */
27
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
28
+ import * as Effect from 'effect/Effect';
29
+ import { runPve } from './distilled-pve.ts';
30
+ import {
31
+ type CephPoolAttributes,
32
+ type CephPoolProps,
33
+ UNSET,
34
+ applications,
35
+ createBody,
36
+ updateBody,
37
+ } from './ceph-pool-form.ts';
38
+ import { bool, int, num, text } from './values.ts';
39
+
40
+ /**
41
+ * One status answer as attributes, or `undefined` for the index-trap shape — the same defensive
42
+ * check ceph-pool.ts's pre-migration `attributes` carried, kept even though a well-formed
43
+ * distilled decode should never hand this function anything else: `GetNodeCephPoolStatusResponse`
44
+ * declares `name` required, so a response shaped like the INDEX (an array) fails the Schema decode
45
+ * before this runs at all. Left in as the same fail-safe rather than trusting that alone.
46
+ *
47
+ * ⚠️ `live` IS CAST PAST `GetNodeCephPoolStatusResponse`'s OWN TS TYPE, the same measured reason
48
+ * node-network-wire.ts's `attributesOf` casts past `GetNodeNetworkResponse`: Effect Schema's
49
+ * structural (non-exact) decode does not strip fields the declared type omits, and the live
50
+ * probe below (2026-09-24) showed extra fields (`statistics`, `autoscale_status`) surviving.
51
+ */
52
+ const attributesOf = (live: unknown, props: CephPoolProps): CephPoolAttributes | undefined => {
53
+ const row = live as Record<string, unknown>;
54
+ if (typeof row['name'] !== 'string') return undefined;
55
+ return {
56
+ applications: applications(row['application_list']),
57
+ crush_rule: text(row['crush_rule']),
58
+ id: int(row['id'], UNSET),
59
+ min_size: int(row['min_size'], UNSET),
60
+ name: props.name,
61
+ node: props.node,
62
+ nodelete: bool(row['nodelete']),
63
+ nopgchange: bool(row['nopgchange']),
64
+ nosizechange: bool(row['nosizechange']),
65
+ pg_autoscale_mode: text(row['pg_autoscale_mode']),
66
+ pg_num: int(row['pg_num'], UNSET),
67
+ pg_num_min: int(row['pg_num_min'], UNSET),
68
+ size: int(row['size'], UNSET),
69
+ target_size: int(row['target_size'], UNSET),
70
+ target_size_ratio: num(row['target_size_ratio'], UNSET),
71
+ };
72
+ };
73
+
74
+ /**
75
+ * The live pool, or `undefined` — folds every failure, exactly as `ops.read` did. Used by `read`,
76
+ * `diff` (via `ceph-pool.ts`'s own hand-written `reconcile`, which predates the factory) and
77
+ * `settle` (ceph-pool-settle.ts) alike, matching the ONE-function shape this family had before.
78
+ *
79
+ * ⛔ `verbose=1` IS WHAT MAKES `applications` VISIBLE AT ALL — ceph-pool.ts's own header has the
80
+ * measured reason and the cost (two extra, unguarded mon commands PVE runs to answer it).
81
+ */
82
+ export const readPoolStatus = (props: CephPoolProps) =>
83
+ runPve(
84
+ props.target,
85
+ 'read',
86
+ false,
87
+ nodes.getNodeCephPoolStatus({ name: props.name, node: props.node, verbose: '1' }),
88
+ ).pipe(
89
+ Effect.map((live) => attributesOf(live, props)),
90
+ Effect.orElseSucceed(() => undefined),
91
+ );
92
+
93
+ /** The two endpoints, as the keys `guardWrite` (distilled-guard.ts) checks both forms against. */
94
+ export const CEPH_POOL_CREATE = 'pve:POST /nodes/{node}/ceph/pool';
95
+ export const CEPH_POOL_UPDATE = 'pve:PUT /nodes/{node}/ceph/pool/{name}';
96
+
97
+ /** The actual `createNodeCephPool` call body — `createBody`, translated. No field is renamed. */
98
+ export const toDistilledCreate = (props: CephPoolProps): nodes.CreateNodeCephPoolRequest =>
99
+ ({
100
+ ...createBody(props),
101
+ node: props.node,
102
+ }) as unknown as nodes.CreateNodeCephPoolRequest;
103
+
104
+ /** The actual `putNodeCephPool` call body — `updateBody`, translated. No field is renamed. */
105
+ export const toDistilledUpdate = (props: CephPoolProps): nodes.PutNodeCephPoolRequest =>
106
+ ({
107
+ ...updateBody(props),
108
+ name: props.name,
109
+ node: props.node,
110
+ }) as unknown as nodes.PutNodeCephPoolRequest;