@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
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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 }
|
|
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
|
-
|
|
101
|
-
const
|
|
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
|
+
});
|