@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.
Files changed (116) 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 +31 -0
  31. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  32. package/dist/proxmox/ceph-daemon.d.ts +40 -6
  33. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  34. package/dist/proxmox/ceph-fs-distilled.d.ts +33 -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 +30 -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 +19 -12
  47. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-pool-wire.d.ts +37 -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 +8 -13
  53. package/dist/proxmox/group-wire.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +605 -483
  55. package/dist/proxmox/index.js.map +20 -17
  56. package/dist/proxmox/storage-wire.d.ts +15 -11
  57. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  58. package/dist/proxmox/unreadable-read.d.ts +7 -7
  59. package/dist/proxmox/user-wire.d.ts +8 -11
  60. package/dist/proxmox/user-wire.d.ts.map +1 -1
  61. package/dist/proxmox/user.d.ts +5 -5
  62. package/docs/argocd-kubernetes.md +100 -0
  63. package/docs/argocd.md +139 -0
  64. package/docs/credentials.md +9 -0
  65. package/docs/distilled-interim.md +15 -3
  66. package/docs/litellm.md +17 -0
  67. package/docs/peers.md +7 -0
  68. package/docs/provider-standard.md +24 -6
  69. package/docs/proxmox.md +20 -0
  70. package/docs/unifi.md +45 -0
  71. package/docs/upstream-conformance-bun.md +67 -0
  72. package/docs/upstream-conformance-conforms.md +55 -0
  73. package/docs/upstream-conformance-families.md +95 -0
  74. package/docs/upstream-conformance-grafana.md +106 -0
  75. package/docs/upstream-conformance.md +20 -298
  76. package/package.json +8 -3
  77. package/src/argocd/cluster.test.ts +120 -0
  78. package/src/argocd/cluster.ts +222 -0
  79. package/src/argocd/credentials.ts +38 -0
  80. package/src/argocd/fake-argocd.ts +60 -0
  81. package/src/argocd/git-credentials.ts +71 -0
  82. package/src/argocd/index.ts +40 -0
  83. package/src/argocd/providers.ts +17 -0
  84. package/src/argocd/repo-creds.test.ts +123 -0
  85. package/src/argocd/repo-creds.ts +115 -0
  86. package/src/argocd/repository.test.ts +167 -0
  87. package/src/argocd/repository.ts +165 -0
  88. package/src/argocd/resource.ts +150 -0
  89. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  90. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  91. package/src/litellm/operations.ts +8 -4
  92. package/src/proxmox/ceph-daemon-form.ts +7 -8
  93. package/src/proxmox/ceph-daemon-wire.ts +112 -0
  94. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  95. package/src/proxmox/ceph-daemon.ts +70 -53
  96. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  97. package/src/proxmox/ceph-fs-distilled.ts +100 -0
  98. package/src/proxmox/ceph-fs-wire.ts +17 -31
  99. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  100. package/src/proxmox/ceph-fs.ts +68 -73
  101. package/src/proxmox/ceph-osd-tree.ts +14 -5
  102. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  103. package/src/proxmox/ceph-osd-write.ts +33 -6
  104. package/src/proxmox/ceph-osd.ts +6 -0
  105. package/src/proxmox/ceph-pool-settle.ts +24 -17
  106. package/src/proxmox/ceph-pool-wire.ts +101 -0
  107. package/src/proxmox/ceph-pool-write.test.ts +146 -0
  108. package/src/proxmox/ceph-pool.ts +87 -98
  109. package/src/proxmox/group-wire.ts +7 -12
  110. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  111. package/src/proxmox/storage-wire.ts +9 -10
  112. package/src/proxmox/typed-absence.test.ts +146 -0
  113. package/src/proxmox/unreadable-read.ts +7 -7
  114. package/src/proxmox/user-wire.ts +7 -10
  115. package/src/proxmox/user.ts +5 -5
  116. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -45,19 +45,19 @@
45
45
  * needs no widening for once. ⚠️ `Sys.Modify` on the ROOT path is broad — it also buys
46
46
  * datacenter options and every other cluster-wide config write — so it is worth knowing that
47
47
  * this credential already has it rather than discovering it the next time something is scoped.
48
+ *
49
+ * ★ MIGRATED OFF `client.ts` (2026-09-24, decision 43, 2c) — ceph-daemon-wire.ts has the rename
50
+ * and why no protocol gap blocks any of the three kinds. The follow-up removes the preserved
51
+ * catch-all read fold: only a successful index without this daemon proves absence.
48
52
  */
49
53
  import { Resource } from 'alchemy';
54
+ import { isResolved } from 'alchemy/Diff';
50
55
  import * as Provider from 'alchemy/Provider';
51
56
  import * as Effect from 'effect/Effect';
52
- import {
53
- DAEMON_ENDPOINTS,
54
- collectionPath,
55
- createForm,
56
- daemonAttributes,
57
- daemonPath,
58
- } from './ceph-daemon-form.ts';
59
- import { pve } from './client.ts';
60
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
57
+ import { DAEMON_ENDPOINTS, createForm } from './ceph-daemon-form.ts';
58
+ import { createDaemon, deleteDaemon, notCreated, readDaemon } from './ceph-daemon-wire.ts';
59
+ import { guardWrite } from './distilled-guard.ts';
60
+ import { type PveRequirements, type WithTarget } from './resource.ts';
61
61
 
62
62
  /**
63
63
  * Which Ceph daemon this is. The discriminant picks the collection, the create parameters and
@@ -74,12 +74,11 @@ export interface CephDaemonProps extends WithTarget {
74
74
  /**
75
75
  * The node the daemon RUNS on. Identity — a daemon cannot be moved, only destroyed and rebuilt.
76
76
  * ⚠️ It is also the node whose API is asked, and the read is only as available as that node: a
77
- * node that is down — or has no Ceph installed, or answers 403 — reads as nothing at all,
78
- * because `pveOperations.read` folds EVERY failure into "absent" (a 404 is a legitimate answer
79
- * there and it cannot tell the two apart). Its daemons then plan as `update`, and reconcile
80
- * POSTs a create for a mon that already exists. PVE refuses that with "monitor already
81
- * exists", and the read-back refuses again — so it fails loudly rather than damaging anything,
82
- * but the message will point at the daemon rather than at the node that would not answer.
77
+ * failed index now propagates its error. Before the typed-read follow-up, a down node,
78
+ * missing Ceph installation or 403 was folded to "absent"; its daemons planned `update`
79
+ * and reconcile POSTed a create for a mon that already existed. PVE refused with "monitor
80
+ * already exists", then read-back failed again, obscuring the original node read error.
81
+ * That incident is why collection failure can never prove a daemon is missing.
83
82
  */
84
83
  node: string;
85
84
  /**
@@ -180,33 +179,41 @@ export const ProxmoxCephDaemon = Resource<ProxmoxCephDaemon>('Proxmox.CephDaemon
180
179
  defaultRemovalPolicy: 'retain',
181
180
  });
182
181
 
183
- const handlers = pveHandlers<CephDaemonProps, CephDaemonAttributes>({
184
- attributes: daemonAttributes,
185
- /** ⛔ The ID path, not the collection — POST is registered on `{id}`, exactly like a metric server. */
186
- collection: daemonPath,
187
- createForm,
188
- /** ⚠️ A function, because the kind picks the endpoint — see `DAEMON_ENDPOINTS`. */
189
- endpoint: (props: CephDaemonProps) => DAEMON_ENDPOINTS[props.kind],
190
- /**
191
- * ⛔ TOTAL, AND THIS IS THE REASON THE FILE EXISTS RATHER THAN A SHORTCUT PAST WRITING IT. Every
192
- * field a declaration can carry is CREATE-ONLY and unreadable — `mon-address` comes back as a
193
- * different kind of string, `hotstandby` comes back as a state Ceph elects — and every field
194
- * the read returns is assigned by Ceph: state, rank, quorum, fs_name, standby_replay, addr,
195
- * version. Nothing is left that is both declared and readable. So the honest comparison is
196
- * "does a daemon of this kind and name exist", which `attributes` has already answered by
197
- * returning a value at all, and anything further would be a diff no write could ever satisfy.
198
- * ⛔ AND ON THIS FAMILY THAT DIFF IS NOT MERELY NOISY. There is no PUT, so resource.ts answers
199
- * `replace` rather than `update` whenever `matches` is false — and replace on a mon is
200
- * delete-then-create against a live quorum. A forever-diff here is a forever-OUTAGE-RISK.
201
- * ⚠️ THE PRICE, STATED PLAINLY: editing `hotstandby` or `mon-address` on a declared daemon
202
- * plans as `noop` and never applies. Same bargain storage.ts strikes for a changed `type`.
203
- * Change one by removing the declaration and re-adding it, ONE DAEMON AT A TIME, reading the
204
- * ⛔ on destroy in the header first.
205
- */
206
- matches: () => true,
207
- /** ⛔ The COLLECTION, because there is no GET on the id path. See the second ⛔ in the header. */
208
- path: collectionPath,
209
- /** ⚠️ No `updateForm`: none of the three has a PUT, so nothing about a daemon is editable. */
182
+ /**
183
+ * ⛔ TOTAL, AND THIS IS THE REASON THE FILE EXISTS RATHER THAN A SHORTCUT PAST WRITING IT. Every
184
+ * field a declaration can carry is CREATE-ONLY and unreadable — `mon-address` comes back as a
185
+ * different kind of string, `hotstandby` comes back as a state Ceph elects — and every field
186
+ * the read returns is assigned by Ceph: state, rank, quorum, fs_name, standby_replay, addr,
187
+ * version. Nothing is left that is both declared and readable. So the honest comparison is
188
+ * "does a daemon of this kind and name exist", which `readDaemon` has already answered by
189
+ * returning a value at all, and anything further would be a diff no write could ever satisfy.
190
+ * ⛔ AND ON THIS FAMILY THAT DIFF IS NOT MERELY NOISY. There is no PUT, so a false mismatch
191
+ * would have to be a REPLACE — and replace on a mon is delete-then-create against a live
192
+ * quorum. A forever-diff here is a forever-OUTAGE-RISK.
193
+ * ⚠️ THE PRICE, STATED PLAINLY: editing `hotstandby` or `mon-address` on a declared daemon
194
+ * plans as `noop` and never applies. Same bargain storage.ts strikes for a changed `type`.
195
+ * Change one by removing the declaration and re-adding it, ONE DAEMON AT A TIME, reading the
196
+ * ⛔ on destroy in the header first.
197
+ */
198
+ /**
199
+ * ★ The factory's shape with no `updateForm`: create if absent, else read back untouched.
200
+ * ⛔ `guardWrite` RUNS RIGHT AFTER THE READ, UNCONDITIONALLY, NOT ONLY ON THE CREATE BRANCH —
201
+ * found on adversarial review, 2026-09-24, matching node-network.ts's own `reconcile`.
202
+ * `reconcile` also runs for an ADOPTED row with no fresh `diff` first (Plan.ts forces it after
203
+ * the probe even when `diff` said noop), so a guard only on the create branch would let a
204
+ * caller that skips `diff` (a resumed apply, a verify harness) reach `createDaemon` with
205
+ * `mon-address` — a real vendor rule — unchecked.
206
+ * ★ EXPORTED SO A TEST CAN CALL IT DIRECTLY, bypassing `diff` — the shape the review's own proof
207
+ * needed: `findProvider` gives back the wrapped service, this gives the plain function.
208
+ */
209
+ export const reconcileDaemon = Effect.fn(function* ({ news }: { news: CephDaemonProps }) {
210
+ const live = yield* readDaemon(news);
211
+ yield* guardWrite(DAEMON_ENDPOINTS[news.kind], createForm(news), live === undefined);
212
+ if (live !== undefined) return live;
213
+ yield* createDaemon(news);
214
+ const after = yield* readDaemon(news);
215
+ if (after === undefined) return yield* Effect.die(notCreated(news));
216
+ return after;
210
217
  });
211
218
 
212
219
  export const ProxmoxCephDaemonProvider = () =>
@@ -214,19 +221,29 @@ export const ProxmoxCephDaemonProvider = () =>
214
221
  ProxmoxCephDaemon,
215
222
  Effect.succeed(
216
223
  ProxmoxCephDaemon.Provider.of({
217
- ...handlers,
224
+ /** ⛔ Empty, like every other resource here — adopting the whole cluster's daemons is not
225
+ * an accident anyone should be one `destroy` away from. Adoption stays explicit. */
226
+ list: () => Effect.succeed([]),
227
+ read: ({ olds }) => readDaemon(olds),
228
+ // ★ Matches the pre-migration factory shape exactly (resource.ts): present is settled.
229
+ diff: Effect.fn(function* ({ news, output }) {
230
+ if (!isResolved(news)) return undefined;
231
+ yield* guardWrite(DAEMON_ENDPOINTS[news.kind], createForm(news), output === undefined);
232
+ if (output === undefined) return undefined;
233
+ const live = yield* readDaemon(news);
234
+ if (live === undefined) {
235
+ yield* guardWrite(DAEMON_ENDPOINTS[news.kind], createForm(news), true);
236
+ return { action: 'update' } as const;
237
+ }
238
+ return { action: 'noop' } as const;
239
+ }),
240
+ reconcile: ({ news }) => reconcileDaemon({ news }),
218
241
  /**
219
- * ⛔ THE ONE HANDLER NOT TAKEN FROM THE FACTORY, AND THE ONLY REASON IS THE PATH SPLIT.
220
- * `ops.destroy` DELETEs `spec.path`, which this resource must point at the collection so
221
- * that `read` works at all; DELETE on the collection is not implemented and would answer
222
- * 501 while the daemon kept running — a failed destroy that reads like a permissions
223
- * problem. Everything else here is the factory's.
224
- * ⚠️ AND IT IS THE DANGEROUS ONE. Re-read the destroy ⛔ in the header before letting a
225
- * plan that removes a mon run: two gone at once is a cluster with no quorum and every
226
- * guest on `rbd-c1` blocked on I/O.
242
+ * ⛔ THE DANGEROUS ONE. Re-read the destroy ⛔ in the header before letting a plan that
243
+ * removes a mon run: two gone at once is a cluster with no quorum and every guest on
244
+ * `rbd-c1` blocked on I/O.
227
245
  */
228
- delete: ({ olds }: { olds: CephDaemonProps }) =>
229
- pve(olds.target, 'provision', 'DELETE', daemonPath(olds)),
246
+ delete: ({ olds }) => deleteDaemon(olds),
230
247
  }),
231
248
  ),
232
249
  );
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `Proxmox.CephFs`'s destroy path — the one place in this migration that composes a distilled
3
+ * call (`readFs`, before and after) with a hand-client one (`destroyFs`, unmigrated — ceph-fs-
4
+ * wire.ts's header has the measured protocol reason) inside a single handler. Flagged by
5
+ * adversarial review as untested anywhere in the repo before this file: `readFs` alone is proven
6
+ * by ceph-fs-write.test.ts and ceph-adopt.test.ts, `destroyFs` alone is unchanged from main, but
7
+ * the two composed together — on a destructive, hard-to-reverse `RemovalPolicy.destroy()` — had
8
+ * no coverage of the seam itself.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Layer from 'effect/Layer';
14
+ import { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
15
+ import { engineOver } from '../verify/fake-engine.ts';
16
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
17
+
18
+ const NODE = 'node-b';
19
+ const NAME = 'oldfs';
20
+ const INDEX = `nodes/${NODE}/ceph/fs`;
21
+ const OBJECT = `${INDEX}/${NAME}`;
22
+ const UPID = 'UPID:node-b:fake:destroyfs';
23
+ const TASK_STATUS = `nodes/${NODE}/tasks/${encodeURIComponent(UPID)}/status`;
24
+
25
+ const declared = () => ProxmoxCephFs(NAME, { name: NAME, node: NODE, target: FAKE_TARGET });
26
+
27
+ describe('destroying a CephFS', () => {
28
+ test(
29
+ 'RemovalPolicy.destroy() then undeclaring: readFs (distilled) finds it, destroyFs ' +
30
+ '(client.ts) removes it, and the distilled read-back confirms it is gone',
31
+ async () => {
32
+ let destroyed = false;
33
+ const fake = fakePve((call: PveCall) => {
34
+ if (call.method === 'GET' && call.path === INDEX) {
35
+ return destroyed
36
+ ? []
37
+ : [{ data_pool: `${NAME}_data`, metadata_pool: `${NAME}_metadata`, name: NAME }];
38
+ }
39
+ if (call.method === 'DELETE' && call.path === OBJECT) {
40
+ destroyed = true;
41
+ return UPID;
42
+ }
43
+ if (call.method === 'GET' && call.path === TASK_STATUS) {
44
+ return { exitstatus: 'OK', status: 'stopped' };
45
+ }
46
+ return undefined;
47
+ });
48
+ await withoutBao(async () => {
49
+ const engine = engineOver(ProxmoxCephFsProvider().pipe(Layer.provideMerge(fake.layer)));
50
+ expect(await engine.deploy(declared())).toEqual({ [NAME]: 'adopted' });
51
+ await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
52
+ await engine.deploy(Effect.void);
53
+ });
54
+ expect(fake.writes()).toEqual([`DELETE ${OBJECT}`]);
55
+ },
56
+ );
57
+
58
+ test('a filesystem already removed by hand: delete is a no-op, not a false DELETE', async () => {
59
+ let removedByHand = false;
60
+ const fake = fakePve((call: PveCall) => {
61
+ if (call.method === 'GET' && call.path === INDEX) {
62
+ return removedByHand
63
+ ? []
64
+ : [{ data_pool: `${NAME}_data`, metadata_pool: `${NAME}_metadata`, name: NAME }];
65
+ }
66
+ return undefined;
67
+ });
68
+ await withoutBao(async () => {
69
+ const engine = engineOver(ProxmoxCephFsProvider().pipe(Layer.provideMerge(fake.layer)));
70
+ expect(await engine.deploy(declared())).toEqual({ [NAME]: 'adopted' });
71
+ await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
72
+ // ⚠️ Cleaned up outside Alchemy, AFTER that redeploy and BEFORE the undeclare below —
73
+ // `readFs` (distilled) already answers absent by the time `delete` runs, the same branch
74
+ // ceph-fs.ts's own ⚠️ on `delete` documents. Removing it any earlier would instead read as
75
+ // drift on the redeploy above and reconcile a create, which is a different test.
76
+ removedByHand = true;
77
+ await engine.deploy(Effect.void);
78
+ });
79
+ expect(fake.writes()).toEqual([]);
80
+ });
81
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `Proxmox.CephFs`'s distilled half: the index read and the create — see ceph-fs-wire.ts's own
3
+ * header for why `destroyFs` stays there, on `client.ts`, unmigrated.
4
+ *
5
+ * ⛔ `nodes.updateNodeCephFs` IS distilled's GENERATOR MISNAMING A CREATE, NOT A SECOND ENDPOINT.
6
+ * Checked directly against its schema (`T.Http({ method: "POST", uri:
7
+ * "/nodes/{node}/ceph/fs/{name}" })`) rather than trusted from the function name — it is the
8
+ * SAME single write PVE registers, `createfs`. Harmless once named here for what it is.
9
+ *
10
+ * ★ THE SETTLE POLL IS A SEPARATE FUNCTION FROM ceph-fs-wire.ts's, NOT A SHARED ONE, because it
11
+ * calls a different task-status operation on a different transport — `nodes.getNodeTaskStatus`
12
+ * here, `pve()` there — but the POLICY is identical on purpose: same cadence
13
+ * (`POLL_SECONDS`/`POLL_ATTEMPTS`, imported, not retyped), same "an unreadable status is not
14
+ * settled yet, not a failure" tolerance. That tolerance is why this calls the RAW distilled
15
+ * operation directly. The former SDK `Task.awaitTask` propagated poll failures; SDK PR #265
16
+ * removed that helper because polling policy belongs in the provider. This bounded wait
17
+ * still tolerates a missing `Sys.Audit` grant or a node mid-restart until its deadline.
18
+ */
19
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
20
+ import * as Effect from 'effect/Effect';
21
+ import type { CephFsProps } from './ceph-fs.ts';
22
+ import { POLL_ATTEMPTS, POLL_SECONDS, createForm, objectPath, readRow } from './ceph-fs-wire.ts';
23
+ import { runPve } from './distilled-pve.ts';
24
+
25
+ /**
26
+ * The index, read via distilled — the same shape `readRow` (ceph-fs-wire.ts) already narrows.
27
+ *
28
+ * ⛔ ABSENCE IS A SUCCESSFUL INDEX WITH NO MATCHING FILESYSTEM. The transport swap
29
+ * initially preserved the generic factory's catch-all fold. The 2026-09-24 follow-up removes it:
30
+ * a failed list is not proof that a filesystem is missing. Task polling below retains its
31
+ * separate bounded wait policy; it does not decide whether a resource exists.
32
+ */
33
+ export const readFs = (props: CephFsProps) =>
34
+ runPve(props.target, 'read', false, nodes.listNodeCephFs({ node: props.node })).pipe(
35
+ Effect.map((rows) => readRow(rows as unknown as Record<string, unknown>, props)),
36
+ );
37
+
38
+ /** `updateNodeCephFs`'s own request shape — `createForm`, translated. No field is renamed. */
39
+ const toDistilledCreate = (props: CephFsProps): nodes.UpdateNodeCephFsRequest =>
40
+ ({
41
+ ...createForm(props),
42
+ name: props.name,
43
+ node: props.node,
44
+ }) as unknown as nodes.UpdateNodeCephFsRequest;
45
+
46
+ /** Wait for the create's forked task — the distilled twin of ceph-fs-wire.ts's `settle`. */
47
+ const settleCreate = (props: CephFsProps, upid: string) =>
48
+ Effect.gen(function* () {
49
+ for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
50
+ const status = yield* runPve(
51
+ props.target,
52
+ 'read',
53
+ false,
54
+ nodes.getNodeTaskStatus({ node: props.node, upid }),
55
+ ).pipe(Effect.orElseSucceed(() => undefined));
56
+ if (status?.status === 'stopped') {
57
+ if (status.exitstatus === 'OK') return;
58
+ return yield* Effect.die(
59
+ new Error(
60
+ `${objectPath(props)}: PVE task ${upid} finished with "${status.exitstatus ?? 'no exit status'}". ` +
61
+ 'The write returned HTTP 200 because it only forked the worker -- the real error is ' +
62
+ `in \`pvesh get /nodes/${props.node}/tasks/${upid}/log\`.`,
63
+ ),
64
+ );
65
+ }
66
+ yield* Effect.sleep(POLL_SECONDS * 1000);
67
+ }
68
+ return yield* Effect.die(
69
+ new Error(
70
+ `${objectPath(props)}: PVE task ${upid} was still running after ` +
71
+ `${String(POLL_ATTEMPTS * POLL_SECONDS)}s, or its status could not be read. Check the ` +
72
+ 'task log, and check that the `read` role holds Sys.Audit on ' +
73
+ `/nodes/${props.node} -- each call mints a new token, so the poller is never the task ` +
74
+ 'owner and is answered 403 rather than told to wait.',
75
+ ),
76
+ );
77
+ });
78
+
79
+ /** POST the create, then wait for the worker. ⛔ The caller still reads back; see ceph-fs.ts. */
80
+ export const createFs = (props: CephFsProps) =>
81
+ Effect.gen(function* () {
82
+ const upid = yield* runPve(
83
+ props.target,
84
+ 'provision',
85
+ true,
86
+ nodes.updateNodeCephFs(toDistilledCreate(props)),
87
+ );
88
+ // ⛔ NO UPID MEANS NO WORKER. PVE answers 200 with `{"data":null}` on calls that did nothing,
89
+ // and here that is indistinguishable from success by status code alone. Refusing now is the
90
+ // difference between a clear failure and a state entry for a filesystem nobody built.
91
+ if (upid === '') {
92
+ return yield* Effect.die(
93
+ new Error(
94
+ `${objectPath(props)}: POST returned no task id. PVE wraps every answer in ` +
95
+ '{"data":...} and can report success on a call that did nothing.',
96
+ ),
97
+ );
98
+ }
99
+ yield* settleCreate(props, upid);
100
+ });
@@ -48,6 +48,17 @@
48
48
  * user needs 'Sys.Audit' permissions on '/nodes/<node>' if they are not the owner of the task."
49
49
  * Grant it on the `read` role, or every create burns the full cap below and reports the wrong
50
50
  * cause.
51
+ *
52
+ * ★ AS OF 2026-09-24 (decision 43's walk-down, 2c) THIS FILE IS `client.ts`'s HALF ONLY. The read
53
+ * (`readRow`) moved to `ceph-fs-distilled.ts`, and so did the create — `nodes.updateNodeCephFs`
54
+ * (distilled's own misnamed POST, confirmed against its `T.Http` annotation) plus a SEPARATE
55
+ * distilled `settle`, using the raw `getNodeTaskStatus` operation in its own tolerant poll loop
56
+ * rather than `Task.awaitTask`, which does not tolerate a poll failure the way this family needs
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.
51
62
  */
52
63
  import * as Effect from 'effect/Effect';
53
64
  import type { CephFsAttributes, CephFsProps } from './ceph-fs.ts';
@@ -97,12 +108,16 @@ type TaskStatus = { readonly status?: string; readonly exitstatus?: string };
97
108
  * itself spends waiting for an MDS to go active, and is short enough that a stuck task fails the
98
109
  * deploy rather than parking it for the afternoon.
99
110
  */
100
- const POLL_SECONDS = 2;
101
- const POLL_ATTEMPTS = 45;
111
+ // ★ EXPORTED so ceph-fs-distilled.ts's OWN settle polls the same cadence and cap — one number.
112
+ export const POLL_SECONDS = 2;
113
+ export const POLL_ATTEMPTS = 45;
102
114
 
103
115
  /** ⚠️ `nodes/{n}/ceph/fs/{name}` — the POST and DELETE target, and NOT where a read goes. */
104
116
  export const objectPath = (props: CephFsProps) => `nodes/${props.node}/ceph/fs/${props.name}`;
105
117
 
118
+ /** The vendor rules `createForm` is checked against — `guardWrite`, distilled-guard.ts. */
119
+ export const CEPH_FS_CREATE = 'pve:POST /nodes/{node}/ceph/fs/{name}';
120
+
106
121
  /** Only the declared flags. ⚠️ An omitted one is PVE's own default, not a value to send. */
107
122
  const field = (name: string, value: string | undefined): Record<string, string> =>
108
123
  value === undefined ? {} : { [name]: value };
@@ -173,35 +188,6 @@ const settle = (target: PveTarget, node: string, upid: string, what: string) =>
173
188
  );
174
189
  });
175
190
 
176
- /**
177
- * POST the create, then wait for the worker. ⛔ The caller still reads back; see ceph-fs.ts.
178
- *
179
- * ⚠️ A CREATE NEEDS A RUNNING *AND* A STANDBY MDS, and refuses BEFORE it forks -- "no running
180
- * Metadata Server (MDS) found!" / "no standby Metadata Server (MDS) found!" are synchronous, so
181
- * they surface as a failed POST rather than as a silent worker. C1 has three (MEASURED: node-c
182
- * `up:active` for cephfs-c1, node-b and node-d `up:standby`), so this is a note for a smaller cluster.
183
- * ⚠️ BOTH WRITES CHECK `Sys.Modify` ON `/` -- the root, with the breadth metric-server.ts warns
184
- * about: granting it buys datacenter options and every other cluster-wide config write too.
185
- */
186
- export const createFs = (props: CephFsProps) =>
187
- Effect.gen(function* () {
188
- const upid = text(
189
- yield* pve<string>(props.target, 'provision', 'POST', objectPath(props), createForm(props)),
190
- );
191
- // ⛔ NO UPID MEANS NO WORKER. PVE answers 200 with `{"data":null}` on calls that did nothing,
192
- // and here that is indistinguishable from success by status code alone. Refusing now is the
193
- // difference between a clear failure and a state entry for a filesystem nobody built.
194
- if (upid === '') {
195
- return yield* Effect.die(
196
- new Error(
197
- `${objectPath(props)}: POST returned no task id. PVE wraps every answer in ` +
198
- '{"data":...} and can report success on a call that did nothing.',
199
- ),
200
- );
201
- }
202
- yield* settle(props.target, props.node, upid, objectPath(props));
203
- });
204
-
205
191
  /**
206
192
  * DELETE the filesystem, then wait for the worker.
207
193
  *
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `Proxmox.CephFs`'s create path, through Alchemy's own Plan and Apply over a fake cluster — the
3
+ * path ceph-adopt.test.ts does not reach (it is adopt-only). Proves the distilled-based create
4
+ * (ceph-fs-distilled.ts) POSTs, polls the forked task via the RAW `getNodeTaskStatus` operation
5
+ * (not `Task.awaitTask` — that file's own header has the measured reason), and reads back.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Layer from 'effect/Layer';
9
+ import { engineOver } from '../verify/fake-engine.ts';
10
+ import { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
11
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
12
+
13
+ const NODE = 'node-b';
14
+ const NAME = 'newfs';
15
+ const INDEX = `nodes/${NODE}/ceph/fs`;
16
+ const OBJECT = `${INDEX}/${NAME}`;
17
+ const UPID = 'UPID:node-b:fake:createfs';
18
+ // ⚠️ PERCENT-ENCODED, MATCHING ceph-fs-wire.ts's OWN `encodeURIComponent(upid)` — the UPID is
19
+ // full of colons, and distilled's `T.Label()` encodes the path segment the same way.
20
+ const TASK_STATUS = `nodes/${NODE}/tasks/${encodeURIComponent(UPID)}/status`;
21
+
22
+ const declared = () => ProxmoxCephFs(NAME, { name: NAME, node: NODE, target: FAKE_TARGET });
23
+
24
+ describe('a genuinely new CephFS', () => {
25
+ test('POSTs, polls the forked task through, and reads the index back', async () => {
26
+ let posted = false;
27
+ let polls = 0;
28
+ const fake = fakePve((call: PveCall) => {
29
+ if (call.method === 'GET' && call.path === INDEX) {
30
+ return posted
31
+ ? [{ data_pool: `${NAME}_data`, metadata_pool: `${NAME}_metadata`, name: NAME }]
32
+ : [];
33
+ }
34
+ if (call.method === 'POST' && call.path === OBJECT) {
35
+ posted = true;
36
+ return UPID;
37
+ }
38
+ if (call.method === 'GET' && call.path === TASK_STATUS) {
39
+ polls += 1;
40
+ // ⚠️ RUNNING ONCE FIRST — proves the poll loop actually loops, not just checks once.
41
+ return polls === 1 ? { status: 'running' } : { exitstatus: 'OK', status: 'stopped' };
42
+ }
43
+ return undefined;
44
+ });
45
+ await withoutBao(async () => {
46
+ const engine = engineOver(ProxmoxCephFsProvider().pipe(Layer.provideMerge(fake.layer)));
47
+ expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
48
+ });
49
+ expect(polls).toBeGreaterThanOrEqual(2);
50
+ expect(fake.writes()).toEqual([`POST ${OBJECT}`]);
51
+ });
52
+
53
+ test('a task that finishes with a real error dies with the task log pointer, not a false create', async () => {
54
+ const fake = fakePve((call: PveCall) => {
55
+ if (call.method === 'GET' && call.path === INDEX) return [];
56
+ if (call.method === 'POST' && call.path === OBJECT) return UPID;
57
+ if (call.method === 'GET' && call.path === TASK_STATUS) {
58
+ return { exitstatus: 'no running Metadata Server (MDS) found!', status: 'stopped' };
59
+ }
60
+ return undefined;
61
+ });
62
+ await withoutBao(async () => {
63
+ const engine = engineOver(ProxmoxCephFsProvider().pipe(Layer.provideMerge(fake.layer)));
64
+ await expect(engine.deploy(declared())).rejects.toBeDefined();
65
+ });
66
+ });
67
+ });