@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.
- 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 +33 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +35 -0
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +14 -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 +31 -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 +10 -4
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +46 -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 +1 -1
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +611 -477
- package/dist/proxmox/index.js.map +15 -12
- package/dist/proxmox/storage-wire.d.ts +8 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +1 -1
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/unifi.md +45 -0
- 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 +115 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +65 -47
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +102 -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 +66 -69
- 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 +13 -7
- package/src/proxmox/ceph-pool-wire.ts +110 -0
- package/src/proxmox/ceph-pool-write.test.ts +145 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- 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; `readDaemon` there keeps this family's
|
|
51
|
+
* pre-migration single-fold shape (ceph-pool-wire.ts explains why that is not left unfixed).
|
|
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
|
|
@@ -180,33 +180,41 @@ export const ProxmoxCephDaemon = Resource<ProxmoxCephDaemon>('Proxmox.CephDaemon
|
|
|
180
180
|
defaultRemovalPolicy: 'retain',
|
|
181
181
|
});
|
|
182
182
|
|
|
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
|
-
|
|
183
|
+
/**
|
|
184
|
+
* ⛔ TOTAL, AND THIS IS THE REASON THE FILE EXISTS RATHER THAN A SHORTCUT PAST WRITING IT. Every
|
|
185
|
+
* field a declaration can carry is CREATE-ONLY and unreadable — `mon-address` comes back as a
|
|
186
|
+
* different kind of string, `hotstandby` comes back as a state Ceph elects — and every field
|
|
187
|
+
* the read returns is assigned by Ceph: state, rank, quorum, fs_name, standby_replay, addr,
|
|
188
|
+
* version. Nothing is left that is both declared and readable. So the honest comparison is
|
|
189
|
+
* "does a daemon of this kind and name exist", which `readDaemon` has already answered by
|
|
190
|
+
* returning a value at all, and anything further would be a diff no write could ever satisfy.
|
|
191
|
+
* ⛔ AND ON THIS FAMILY THAT DIFF IS NOT MERELY NOISY. There is no PUT, so a false mismatch
|
|
192
|
+
* would have to be a REPLACE — and replace on a mon is delete-then-create against a live
|
|
193
|
+
* quorum. A forever-diff here is a forever-OUTAGE-RISK.
|
|
194
|
+
* ⚠️ THE PRICE, STATED PLAINLY: editing `hotstandby` or `mon-address` on a declared daemon
|
|
195
|
+
* plans as `noop` and never applies. Same bargain storage.ts strikes for a changed `type`.
|
|
196
|
+
* Change one by removing the declaration and re-adding it, ONE DAEMON AT A TIME, reading the
|
|
197
|
+
* ⛔ on destroy in the header first.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* ★ The factory's shape with no `updateForm`: create if absent, else read back untouched.
|
|
201
|
+
* ⛔ `guardWrite` RUNS RIGHT AFTER THE READ, UNCONDITIONALLY, NOT ONLY ON THE CREATE BRANCH —
|
|
202
|
+
* found on adversarial review, 2026-09-24, matching node-network.ts's own `reconcile`.
|
|
203
|
+
* `reconcile` also runs for an ADOPTED row with no fresh `diff` first (Plan.ts forces it after
|
|
204
|
+
* the probe even when `diff` said noop), so a guard only on the create branch would let a
|
|
205
|
+
* caller that skips `diff` (a resumed apply, a verify harness) reach `createDaemon` with
|
|
206
|
+
* `mon-address` — a real vendor rule — unchecked.
|
|
207
|
+
* ★ EXPORTED SO A TEST CAN CALL IT DIRECTLY, bypassing `diff` — the shape the review's own proof
|
|
208
|
+
* needed: `findProvider` gives back the wrapped service, this gives the plain function.
|
|
209
|
+
*/
|
|
210
|
+
export const reconcileDaemon = Effect.fn(function* ({ news }: { news: CephDaemonProps }) {
|
|
211
|
+
const live = yield* readDaemon(news);
|
|
212
|
+
yield* guardWrite(DAEMON_ENDPOINTS[news.kind], createForm(news), live === undefined);
|
|
213
|
+
if (live !== undefined) return live;
|
|
214
|
+
yield* createDaemon(news);
|
|
215
|
+
const after = yield* readDaemon(news);
|
|
216
|
+
if (after === undefined) return yield* Effect.die(notCreated(news));
|
|
217
|
+
return after;
|
|
210
218
|
});
|
|
211
219
|
|
|
212
220
|
export const ProxmoxCephDaemonProvider = () =>
|
|
@@ -214,19 +222,29 @@ export const ProxmoxCephDaemonProvider = () =>
|
|
|
214
222
|
ProxmoxCephDaemon,
|
|
215
223
|
Effect.succeed(
|
|
216
224
|
ProxmoxCephDaemon.Provider.of({
|
|
217
|
-
|
|
225
|
+
/** ⛔ Empty, like every other resource here — adopting the whole cluster's daemons is not
|
|
226
|
+
* an accident anyone should be one `destroy` away from. Adoption stays explicit. */
|
|
227
|
+
list: () => Effect.succeed([]),
|
|
228
|
+
read: ({ olds }) => readDaemon(olds),
|
|
229
|
+
// ★ Matches the pre-migration factory shape exactly (resource.ts): present is settled.
|
|
230
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
231
|
+
if (!isResolved(news)) return undefined;
|
|
232
|
+
yield* guardWrite(DAEMON_ENDPOINTS[news.kind], createForm(news), output === undefined);
|
|
233
|
+
if (output === undefined) return undefined;
|
|
234
|
+
const live = yield* readDaemon(news);
|
|
235
|
+
if (live === undefined) {
|
|
236
|
+
yield* guardWrite(DAEMON_ENDPOINTS[news.kind], createForm(news), true);
|
|
237
|
+
return { action: 'update' } as const;
|
|
238
|
+
}
|
|
239
|
+
return { action: 'noop' } as const;
|
|
240
|
+
}),
|
|
241
|
+
reconcile: ({ news }) => reconcileDaemon({ news }),
|
|
218
242
|
/**
|
|
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.
|
|
243
|
+
* ⛔ THE DANGEROUS ONE. Re-read the destroy ⛔ in the header before letting a plan that
|
|
244
|
+
* removes a mon run: two gone at once is a cluster with no quorum and every guest on
|
|
245
|
+
* `rbd-c1` blocked on I/O.
|
|
227
246
|
*/
|
|
228
|
-
delete: ({ olds }
|
|
229
|
-
pve(olds.target, 'provision', 'DELETE', daemonPath(olds)),
|
|
247
|
+
delete: ({ olds }) => deleteDaemon(olds),
|
|
230
248
|
}),
|
|
231
249
|
),
|
|
232
250
|
);
|
|
@@ -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,102 @@
|
|
|
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 rather than `@distilled.cloud/proxmox`'s own `Task.awaitTask` — CHECKED
|
|
16
|
+
* against its source (`task.ts`): it propagates a poll failure as a genuine typed
|
|
17
|
+
* `GetNodeTaskStatusError` instead of treating it as "not yet", which would turn a missing
|
|
18
|
+
* `Sys.Audit` grant or a node mid-restart into a failed create instead of a patient one.
|
|
19
|
+
*/
|
|
20
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
21
|
+
import * as Effect from 'effect/Effect';
|
|
22
|
+
import type { CephFsProps } from './ceph-fs.ts';
|
|
23
|
+
import { POLL_ATTEMPTS, POLL_SECONDS, createForm, objectPath, readRow } from './ceph-fs-wire.ts';
|
|
24
|
+
import { runPve } from './distilled-pve.ts';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The index, read via distilled — the same shape `readRow` (ceph-fs-wire.ts) already narrows.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ FOLDS EVERY FAILURE TO `undefined`, matching the pre-migration `pveOperations.read` this
|
|
30
|
+
* family used (`resource.ts`, `Effect.orElseSucceed`) — the same single-fold shape
|
|
31
|
+
* ceph-pool-wire.ts's `readPoolStatus` preserves and explains: this migration's own rule is
|
|
32
|
+
* behaviour surviving byte-for-byte, not the newer dual-path pattern gaining a new family.
|
|
33
|
+
*/
|
|
34
|
+
export const readFs = (props: CephFsProps) =>
|
|
35
|
+
runPve(props.target, 'read', false, nodes.listNodeCephFs({ node: props.node })).pipe(
|
|
36
|
+
Effect.map((rows) => readRow(rows as unknown as Record<string, unknown>, props)),
|
|
37
|
+
Effect.orElseSucceed(() => undefined),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** `updateNodeCephFs`'s own request shape — `createForm`, translated. No field is renamed. */
|
|
41
|
+
const toDistilledCreate = (props: CephFsProps): nodes.UpdateNodeCephFsRequest =>
|
|
42
|
+
({
|
|
43
|
+
...createForm(props),
|
|
44
|
+
name: props.name,
|
|
45
|
+
node: props.node,
|
|
46
|
+
}) as unknown as nodes.UpdateNodeCephFsRequest;
|
|
47
|
+
|
|
48
|
+
/** Wait for the create's forked task — the distilled twin of ceph-fs-wire.ts's `settle`. */
|
|
49
|
+
const settleCreate = (props: CephFsProps, upid: string) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
52
|
+
const status = yield* runPve(
|
|
53
|
+
props.target,
|
|
54
|
+
'read',
|
|
55
|
+
false,
|
|
56
|
+
nodes.getNodeTaskStatus({ node: props.node, upid }),
|
|
57
|
+
).pipe(Effect.orElseSucceed(() => undefined));
|
|
58
|
+
if (status?.status === 'stopped') {
|
|
59
|
+
if (status.exitstatus === 'OK') return;
|
|
60
|
+
return yield* Effect.die(
|
|
61
|
+
new Error(
|
|
62
|
+
`${objectPath(props)}: PVE task ${upid} finished with "${status.exitstatus ?? 'no exit status'}". ` +
|
|
63
|
+
'The write returned HTTP 200 because it only forked the worker -- the real error is ' +
|
|
64
|
+
`in \`pvesh get /nodes/${props.node}/tasks/${upid}/log\`.`,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
yield* Effect.sleep(POLL_SECONDS * 1000);
|
|
69
|
+
}
|
|
70
|
+
return yield* Effect.die(
|
|
71
|
+
new Error(
|
|
72
|
+
`${objectPath(props)}: PVE task ${upid} was still running after ` +
|
|
73
|
+
`${String(POLL_ATTEMPTS * POLL_SECONDS)}s, or its status could not be read. Check the ` +
|
|
74
|
+
'task log, and check that the `read` role holds Sys.Audit on ' +
|
|
75
|
+
`/nodes/${props.node} -- each call mints a new token, so the poller is never the task ` +
|
|
76
|
+
'owner and is answered 403 rather than told to wait.',
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** POST the create, then wait for the worker. ⛔ The caller still reads back; see ceph-fs.ts. */
|
|
82
|
+
export const createFs = (props: CephFsProps) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
const upid = yield* runPve(
|
|
85
|
+
props.target,
|
|
86
|
+
'provision',
|
|
87
|
+
true,
|
|
88
|
+
nodes.updateNodeCephFs(toDistilledCreate(props)),
|
|
89
|
+
);
|
|
90
|
+
// ⛔ NO UPID MEANS NO WORKER. PVE answers 200 with `{"data":null}` on calls that did nothing,
|
|
91
|
+
// and here that is indistinguishable from success by status code alone. Refusing now is the
|
|
92
|
+
// difference between a clear failure and a state entry for a filesystem nobody built.
|
|
93
|
+
if (upid === '') {
|
|
94
|
+
return yield* Effect.die(
|
|
95
|
+
new Error(
|
|
96
|
+
`${objectPath(props)}: POST returned no task id. PVE wraps every answer in ` +
|
|
97
|
+
'{"data":...} and can report success on a call that did nothing.',
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
yield* settleCreate(props, upid);
|
|
102
|
+
});
|
|
@@ -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
|
+
});
|