@homeflare/alchemy 0.35.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/grafana/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +204 -90
- package/dist/grafana/index.js.map +7 -4
- package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
- package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
- package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
- package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
- package/dist/grafana/notification-policy-model.d.ts +53 -0
- package/dist/grafana/notification-policy-model.d.ts.map +1 -0
- package/dist/grafana/notification-policy.d.ts +57 -0
- package/dist/grafana/notification-policy.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.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/grafana-alerting.md +3 -2
- package/docs/grafana-notification-policy.md +127 -0
- package/docs/grafana.md +12 -8
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/unifi.md +45 -0
- package/docs/upstream-conformance.md +19 -5
- 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/grafana/index.ts +11 -4
- package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
- package/src/grafana/notification-policy-drop-warning.ts +60 -0
- package/src/grafana/notification-policy-fixtures.ts +27 -0
- package/src/grafana/notification-policy-model.test.ts +104 -0
- package/src/grafana/notification-policy-model.ts +100 -0
- package/src/grafana/notification-policy-refusals.test.ts +119 -0
- package/src/grafana/notification-policy.test.ts +86 -0
- package/src/grafana/notification-policy.ts +191 -0
- package/src/grafana/providers.ts +2 -0
- 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
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*
|
|
30
30
|
*/
|
|
31
31
|
import type { CephDaemonAttributes, CephDaemonKind, CephDaemonProps } from './ceph-daemon.ts';
|
|
32
|
-
import type {
|
|
32
|
+
import type { EndpointKey } from './constraints.ts';
|
|
33
33
|
import { bool, flag, num, text } from './values.ts';
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -38,19 +38,18 @@ import { bool, flag, num, text } from './values.ts';
|
|
|
38
38
|
* ⛔ ONE PER KIND, NOT A `{type}` PATH. PVE registers `mds`, `mgr` and `mon` as three separate
|
|
39
39
|
* nodes with three separate parameter schemas — `hotstandby` belongs to the first and
|
|
40
40
|
* `mon-address` to the third — so a single key would enforce one kind's rules on all three.
|
|
41
|
-
*
|
|
41
|
+
* `ceph-daemon.ts` indexes this by `props.kind` for exactly that reason, on every `guardWrite`.
|
|
42
42
|
* ⛔ THE STRINGS MUST STAY LITERALS. `codegen/constraints.ts` finds the endpoints to table by
|
|
43
43
|
* SCANNING THIS PACKAGE'S TEXT, so a key assembled from `props.kind` would be tabled by
|
|
44
44
|
* nothing and `constraintsFor` would throw on the first deploy that reached it.
|
|
45
45
|
* ⚠️ THE PARAMETER NAMES DIFFER AND ARE THE VENDOR'S OWN — `{name}`, `{id}`, `{monid}`. The
|
|
46
46
|
* generator resolves each against the schema, so a tidied-up spelling stops the build.
|
|
47
|
-
* ⚠️ NO `update`
|
|
48
|
-
* `updateForm` either.
|
|
47
|
+
* ⚠️ NO `update` HERE: none of the three kinds has a PUT — ceph-daemon-wire.ts's own header.
|
|
49
48
|
*/
|
|
50
|
-
export const DAEMON_ENDPOINTS: Readonly<Record<CephDaemonKind,
|
|
51
|
-
mds:
|
|
52
|
-
mgr:
|
|
53
|
-
mon:
|
|
49
|
+
export const DAEMON_ENDPOINTS: Readonly<Record<CephDaemonKind, EndpointKey>> = {
|
|
50
|
+
mds: 'pve:POST /nodes/{node}/ceph/mds/{name}',
|
|
51
|
+
mgr: 'pve:POST /nodes/{node}/ceph/mgr/{id}',
|
|
52
|
+
mon: 'pve:POST /nodes/{node}/ceph/mon/{monid}',
|
|
54
53
|
};
|
|
55
54
|
|
|
56
55
|
/**
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephDaemon`'s distilled wire lane: one dispatch per kind, because mon/mgr/mds are three
|
|
3
|
+
* separate distilled operations with three separate request shapes (`{node,monid}` /
|
|
4
|
+
* `{node,id}` / `{node,name}`) — the SAME reason `DAEMON_ENDPOINTS` (ceph-daemon-form.ts) is a
|
|
5
|
+
* per-kind table rather than one templated string.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ ONE READ FUNCTION, NOT THE readXOrFail/foldingX PAIR — the same deliberate, measured
|
|
8
|
+
* departure ceph-pool-wire.ts and ceph-fs-distilled.ts explain in full: this family's
|
|
9
|
+
* pre-migration `pveOperations.read` (resource.ts) already folded every failure unconditionally,
|
|
10
|
+
* with no `output`-branching, so `readDaemon` below reproduces that exact single-fold shape
|
|
11
|
+
* rather than gaining the newer dual-path pattern along the way.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ `mon_address` IS THE ONE RENAME THIS FAMILY NEEDS — distilled's generator maps the wire name
|
|
14
|
+
* `mon-address` to `mon_address` on `UpdateNodeCephMonRequest` (`T.Body("mon-address")`,
|
|
15
|
+
* confirmed against its schema), the same one-key pattern node-network-form.ts's `RENAMED`
|
|
16
|
+
* table carries for three fields. `hotstandby` needs no rename.
|
|
17
|
+
*/
|
|
18
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import type { CephDaemonKind, CephDaemonProps } from './ceph-daemon.ts';
|
|
21
|
+
import { daemonAttributes, daemonId, daemonPath } from './ceph-daemon-form.ts';
|
|
22
|
+
import { runPve } from './distilled-pve.ts';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The index, per kind — the same array `daemonAttributes`/`findRow` already narrow.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ CAST TO `unknown[]` PER BRANCH, NOT LEFT TO INFERENCE. mon/mgr/mds answer three DIFFERENT
|
|
28
|
+
* row shapes (`ListNodeCephMonResponseBodyItem` and its two siblings), and a switch returning
|
|
29
|
+
* each as-is infers their INTERSECTION rather than their union — TypeScript refuses that under
|
|
30
|
+
* `exactOptionalPropertyTypes`. `daemonAttributes` already treats the row generically (it reads
|
|
31
|
+
* by string key, the same shape acl.ts narrows), so the cast costs nothing real.
|
|
32
|
+
*/
|
|
33
|
+
const listRows = (props: CephDaemonProps) => {
|
|
34
|
+
const node = props.node;
|
|
35
|
+
switch (props.kind) {
|
|
36
|
+
case 'mon':
|
|
37
|
+
return runPve(props.target, 'read', false, nodes.listNodeCephMon({ node })).pipe(
|
|
38
|
+
Effect.map((rows): unknown[] => rows),
|
|
39
|
+
);
|
|
40
|
+
case 'mgr':
|
|
41
|
+
return runPve(props.target, 'read', false, nodes.listNodeCephMgr({ node })).pipe(
|
|
42
|
+
Effect.map((rows): unknown[] => rows),
|
|
43
|
+
);
|
|
44
|
+
case 'mds':
|
|
45
|
+
return runPve(props.target, 'read', false, nodes.listNodeCephMds({ node })).pipe(
|
|
46
|
+
Effect.map((rows): unknown[] => rows),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** ⛔ FOLDS EVERY FAILURE TO `undefined` — see this file's own header for why. */
|
|
52
|
+
export const readDaemon = (props: CephDaemonProps) =>
|
|
53
|
+
listRows(props).pipe(
|
|
54
|
+
Effect.map((rows) => daemonAttributes(rows as unknown as Record<string, unknown>, props)),
|
|
55
|
+
Effect.orElseSucceed(() => undefined),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** POST — distilled's generator misnames it "update" (checked against `T.Http`); it is a create. */
|
|
59
|
+
export const createDaemon = (props: CephDaemonProps) => {
|
|
60
|
+
const node = props.node;
|
|
61
|
+
const id = daemonId(props);
|
|
62
|
+
switch (props.kind) {
|
|
63
|
+
case 'mon':
|
|
64
|
+
return runPve(
|
|
65
|
+
props.target,
|
|
66
|
+
'provision',
|
|
67
|
+
true,
|
|
68
|
+
nodes.updateNodeCephMon({
|
|
69
|
+
...(props['mon-address'] === undefined ? {} : { mon_address: props['mon-address'] }),
|
|
70
|
+
monid: id,
|
|
71
|
+
node,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
case 'mgr':
|
|
75
|
+
return runPve(props.target, 'provision', true, nodes.updateNodeCephMgr({ id, node }));
|
|
76
|
+
case 'mds':
|
|
77
|
+
return runPve(
|
|
78
|
+
props.target,
|
|
79
|
+
'provision',
|
|
80
|
+
true,
|
|
81
|
+
nodes.updateNodeCephMds({
|
|
82
|
+
...(props.hotstandby === undefined ? {} : { hotstandby: props.hotstandby ? '1' : '0' }),
|
|
83
|
+
name: id,
|
|
84
|
+
node,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** ⚠️ NO FLAGS ON ANY OF THE THREE — unlike CephFs/CephOsd, nothing here is at risk of a
|
|
91
|
+
* DELETE-body PVE would ignore, because nothing here is ever sent. */
|
|
92
|
+
export const deleteDaemon = (props: CephDaemonProps) => {
|
|
93
|
+
const node = props.node;
|
|
94
|
+
const id = daemonId(props);
|
|
95
|
+
switch (props.kind) {
|
|
96
|
+
case 'mon':
|
|
97
|
+
return runPve(props.target, 'provision', true, nodes.deleteNodeCephMon({ monid: id, node }));
|
|
98
|
+
case 'mgr':
|
|
99
|
+
return runPve(props.target, 'provision', true, nodes.deleteNodeCephMgr({ id, node }));
|
|
100
|
+
case 'mds':
|
|
101
|
+
return runPve(props.target, 'provision', true, nodes.deleteNodeCephMds({ name: id, node }));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Re-exported so ceph-daemon.ts can narrow a switch exhaustively without importing the union twice. */
|
|
106
|
+
export type { CephDaemonKind };
|
|
107
|
+
|
|
108
|
+
/** ★ Moved here from ceph-daemon.ts's `reconcile` for the 250-line cap — matches ceph-fs.ts's own
|
|
109
|
+
* `notCreated`/`notDestroyed` seam: the message, not the decision to die, lives with the calls. */
|
|
110
|
+
export const notCreated = (props: CephDaemonProps) =>
|
|
111
|
+
new Error(
|
|
112
|
+
`${daemonPath(props)}: no ${props.kind} named ${daemonId(props)} in GET nodes/` +
|
|
113
|
+
`${props.node}/ceph/${props.kind} after the create task finished. POST answers a UPID, ` +
|
|
114
|
+
'not a result -- watch the task in the PVE UI (ceph-daemon-form.ts).',
|
|
115
|
+
);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephDaemon`'s write paths, through Alchemy's own Plan and Apply over a fake cluster —
|
|
3
|
+
* the paths ceph-adopt.test.ts does not reach: it is adopt-only by design (every assertion there
|
|
4
|
+
* ends on `fake.writes()` empty). Proves the distilled-based create (ceph-daemon-wire.ts) POSTs
|
|
5
|
+
* with the right per-kind body — `mon-address` renamed to `mon_address` for mon, `hotstandby` for
|
|
6
|
+
* mds — reads back, and that delete sends exactly one DELETE.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
13
|
+
import { ProxmoxCephDaemon, ProxmoxCephDaemonProvider, reconcileDaemon } from './ceph-daemon.ts';
|
|
14
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
15
|
+
|
|
16
|
+
const NODE = 'node-b';
|
|
17
|
+
|
|
18
|
+
describe('a genuinely new mon', () => {
|
|
19
|
+
test('POSTs mon-address as mon_address, and reads the collection back', async () => {
|
|
20
|
+
const NAME = 'node-b';
|
|
21
|
+
const COLLECTION = `nodes/${NODE}/ceph/mon`;
|
|
22
|
+
const OBJECT = `${COLLECTION}/${NAME}`;
|
|
23
|
+
let created = false;
|
|
24
|
+
const fake = fakePve((call: PveCall) => {
|
|
25
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
26
|
+
return created ? [{ addr: '198.51.100.12:6789/0', name: NAME, state: 'running' }] : [];
|
|
27
|
+
}
|
|
28
|
+
// ⚠️ POST lands on the daemon's OWN path (`{monid}`), not the collection — see
|
|
29
|
+
// ceph-daemon-form.ts's own ⛔ on `collectionPath`/`daemonPath`.
|
|
30
|
+
if (call.method === 'POST' && call.path === OBJECT) {
|
|
31
|
+
created = true;
|
|
32
|
+
return `UPID:${NODE}:fake:createmon`;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
});
|
|
36
|
+
await withoutBao(async () => {
|
|
37
|
+
const engine = engineOver(ProxmoxCephDaemonProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
38
|
+
const declared = () =>
|
|
39
|
+
ProxmoxCephDaemon('node-b-mon', {
|
|
40
|
+
kind: 'mon',
|
|
41
|
+
'mon-address': '198.51.100.12',
|
|
42
|
+
name: NAME,
|
|
43
|
+
node: NODE,
|
|
44
|
+
target: FAKE_TARGET,
|
|
45
|
+
});
|
|
46
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-mon': 'create' });
|
|
47
|
+
});
|
|
48
|
+
expect(fake.calls.find((c) => c.method === 'POST')?.form['mon-address']).toBe('198.51.100.12');
|
|
49
|
+
expect(fake.writes()).toEqual([`POST ${OBJECT}`]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('a genuinely new mds', () => {
|
|
54
|
+
test('POSTs hotstandby, and reads the collection back', async () => {
|
|
55
|
+
const NAME = 'node-b';
|
|
56
|
+
const COLLECTION = `nodes/${NODE}/ceph/mds`;
|
|
57
|
+
const OBJECT = `${COLLECTION}/${NAME}`;
|
|
58
|
+
let created = false;
|
|
59
|
+
const fake = fakePve((call: PveCall) => {
|
|
60
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
61
|
+
return created ? [{ name: NAME, state: 'up:standby' }] : [];
|
|
62
|
+
}
|
|
63
|
+
if (call.method === 'POST' && call.path === OBJECT) {
|
|
64
|
+
created = true;
|
|
65
|
+
return `UPID:${NODE}:fake:createmds`;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
});
|
|
69
|
+
await withoutBao(async () => {
|
|
70
|
+
const engine = engineOver(ProxmoxCephDaemonProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
71
|
+
const declared = () =>
|
|
72
|
+
ProxmoxCephDaemon('node-b-mds', {
|
|
73
|
+
hotstandby: true,
|
|
74
|
+
kind: 'mds',
|
|
75
|
+
name: NAME,
|
|
76
|
+
node: NODE,
|
|
77
|
+
target: FAKE_TARGET,
|
|
78
|
+
});
|
|
79
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-mds': 'create' });
|
|
80
|
+
});
|
|
81
|
+
expect(fake.calls.find((c) => c.method === 'POST')?.form['hotstandby']).toBe('1');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('destroying a mgr', () => {
|
|
86
|
+
test('RemovalPolicy.destroy() then undeclaring sends exactly one DELETE', async () => {
|
|
87
|
+
const NAME = 'node-b';
|
|
88
|
+
const COLLECTION = `nodes/${NODE}/ceph/mgr`;
|
|
89
|
+
const OBJECT = `${COLLECTION}/${NAME}`;
|
|
90
|
+
let deleted = false;
|
|
91
|
+
const fake = fakePve((call: PveCall) => {
|
|
92
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
93
|
+
return deleted ? [] : [{ name: NAME, state: 'active' }];
|
|
94
|
+
}
|
|
95
|
+
if (call.method === 'DELETE' && call.path === OBJECT) {
|
|
96
|
+
deleted = true;
|
|
97
|
+
return `UPID:${NODE}:fake:destroymgr`;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
});
|
|
101
|
+
const declared = () =>
|
|
102
|
+
ProxmoxCephDaemon('node-b-mgr', { kind: 'mgr', name: NAME, node: NODE, target: FAKE_TARGET });
|
|
103
|
+
await withoutBao(async () => {
|
|
104
|
+
const engine = engineOver(ProxmoxCephDaemonProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
105
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-mgr': 'adopted' });
|
|
106
|
+
await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
|
|
107
|
+
await engine.deploy(Effect.void);
|
|
108
|
+
});
|
|
109
|
+
expect(fake.writes()).toEqual([`DELETE ${OBJECT}`]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("reconcile called directly, no diff first — the adversarial review's own proof", () => {
|
|
114
|
+
/**
|
|
115
|
+
* ⚠️ STRUCTURAL, NOT A REFUSAL — checked directly before writing this test:
|
|
116
|
+
* `constraints.ts`'s `violations()` never checks a bare `format` rule (`mon-address`'s own
|
|
117
|
+
* `{"format":"ip-list","type":"string"}` carries no `pattern`), and `mds`/`mgr`'s create
|
|
118
|
+
* endpoints have EMPTY constraint tables — so nothing in this family's vendor table can
|
|
119
|
+
* actually refuse a bad value today (a separate, recorded gap; not this PR's to fix). What IS
|
|
120
|
+
* provable, and what the review's finding was actually about, is that `guardWrite` — and so
|
|
121
|
+
* `createForm(news)`, which it must evaluate to build the value it checks — runs on the
|
|
122
|
+
* ADOPTED-ROW PATH at all, before the `live !== undefined` early return. A getter on
|
|
123
|
+
* `mon-address` proves it: `createForm` is the ONLY thing that reads this field on that path
|
|
124
|
+
* (`createDaemon`, the other reader, is never reached when `live` is already defined), so it
|
|
125
|
+
* firing at all means the guard ran; it firing zero times is exactly the bug the review found.
|
|
126
|
+
*/
|
|
127
|
+
test('guardWrite (and the createForm it checks) still runs on an adopted row, before the early return', async () => {
|
|
128
|
+
const NAME = 'node-b';
|
|
129
|
+
const COLLECTION = `nodes/${NODE}/ceph/mon`;
|
|
130
|
+
const fake = fakePve((call: PveCall) => {
|
|
131
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
132
|
+
return [{ addr: '198.51.100.12:6789/0', name: NAME, state: 'running' }];
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
});
|
|
136
|
+
let reads = 0;
|
|
137
|
+
const news = {
|
|
138
|
+
get 'mon-address'() {
|
|
139
|
+
reads += 1;
|
|
140
|
+
return '198.51.100.99';
|
|
141
|
+
},
|
|
142
|
+
kind: 'mon' as const,
|
|
143
|
+
name: NAME,
|
|
144
|
+
node: NODE,
|
|
145
|
+
target: FAKE_TARGET,
|
|
146
|
+
};
|
|
147
|
+
await withoutBao(async () => {
|
|
148
|
+
// ⛔ NOT engine.deploy() — this calls the exported `reconcileDaemon` straight, the way a
|
|
149
|
+
// resumed apply or a verify harness would, with no `diff` run first.
|
|
150
|
+
const after = await Effect.runPromise(
|
|
151
|
+
reconcileDaemon({ news }).pipe(Effect.provide(fake.layer)),
|
|
152
|
+
);
|
|
153
|
+
expect(after.state).toBe('running');
|
|
154
|
+
});
|
|
155
|
+
expect(reads).toBeGreaterThan(0);
|
|
156
|
+
expect(fake.writes()).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -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
|
+
});
|