@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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephPool`'s distilled wire lane: the status read, and the two request translations.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF ceph-pool-form.ts FOR THE 250-LINE CAP (that file is already near it) — the seam
|
|
5
|
+
* is "touches the cluster or not", the same one ceph-pool-form.ts itself draws against
|
|
6
|
+
* ceph-pool.ts. Nothing in ceph-pool-form.ts changed: `createBody`/`updateBody` still build the
|
|
7
|
+
* PVE form, unaware distilled exists: this file is the ONLY place that translates one into a
|
|
8
|
+
* typed distilled request.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ ONE READ FUNCTION, NOT THE readXOrFail/foldingX PAIR user.ts/group.ts/storage.ts/zfs-pool.ts/
|
|
11
|
+
* node-network-wire.ts CARRY. THIS IS A DELIBERATE, MEASURED DIFFERENCE, NOT AN OVERSIGHT. Those
|
|
12
|
+
* families were BUILT with (or later given, after the NodeNetwork adversarial review) the
|
|
13
|
+
* dual-path pattern because their `diff`/`read` hooks needed to tell "genuinely absent" apart
|
|
14
|
+
* from "a transient failure" at specific call sites. CephPool's PRE-MIGRATION behaviour never
|
|
15
|
+
* had that distinction: `resource.ts`'s generic `pveOperations.read` — what `ops.read`/`ops.diff`
|
|
16
|
+
* called before this PR — folds EVERY failure into `undefined` unconditionally
|
|
17
|
+
* (`Effect.orElseSucceed(() => undefined)`, no `output`-branching, checked directly against
|
|
18
|
+
* `resource.ts` 2026-09-24). This migration's own rule is that every existing refusal and guard
|
|
19
|
+
* survives byte-for-byte — not that it gets upgraded on the way past. `readPoolStatus` below
|
|
20
|
+
* reproduces that exact single-fold shape; it does not add the newer pattern.
|
|
21
|
+
* ⚠️ WORTH A LATER, SEPARATELY-SCOPED DECISION: this means CephPool (like CephDaemon) still folds
|
|
22
|
+
* a refused OpenBao mint into "absent" too, same as every other pre-`unreadable-read.ts` family
|
|
23
|
+
* — the cries-wolf class `Proxmox.NodeNetwork`/`Proxmox.ZfsPool`/`Proxmox.Storage` were fixed
|
|
24
|
+
* against. Noted here rather than fixed here, since fixing it is a behaviour change this PR's
|
|
25
|
+
* own rules do not ask for.
|
|
26
|
+
*/
|
|
27
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
28
|
+
import * as Effect from 'effect/Effect';
|
|
29
|
+
import { runPve } from './distilled-pve.ts';
|
|
30
|
+
import {
|
|
31
|
+
type CephPoolAttributes,
|
|
32
|
+
type CephPoolProps,
|
|
33
|
+
UNSET,
|
|
34
|
+
applications,
|
|
35
|
+
createBody,
|
|
36
|
+
updateBody,
|
|
37
|
+
} from './ceph-pool-form.ts';
|
|
38
|
+
import { bool, int, num, text } from './values.ts';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One status answer as attributes, or `undefined` for the index-trap shape — the same defensive
|
|
42
|
+
* check ceph-pool.ts's pre-migration `attributes` carried, kept even though a well-formed
|
|
43
|
+
* distilled decode should never hand this function anything else: `GetNodeCephPoolStatusResponse`
|
|
44
|
+
* declares `name` required, so a response shaped like the INDEX (an array) fails the Schema decode
|
|
45
|
+
* before this runs at all. Left in as the same fail-safe rather than trusting that alone.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ `live` IS CAST PAST `GetNodeCephPoolStatusResponse`'s OWN TS TYPE, the same measured reason
|
|
48
|
+
* node-network-wire.ts's `attributesOf` casts past `GetNodeNetworkResponse`: Effect Schema's
|
|
49
|
+
* structural (non-exact) decode does not strip fields the declared type omits, and the live
|
|
50
|
+
* probe below (2026-09-24) showed extra fields (`statistics`, `autoscale_status`) surviving.
|
|
51
|
+
*/
|
|
52
|
+
const attributesOf = (live: unknown, props: CephPoolProps): CephPoolAttributes | undefined => {
|
|
53
|
+
const row = live as Record<string, unknown>;
|
|
54
|
+
if (typeof row['name'] !== 'string') return undefined;
|
|
55
|
+
return {
|
|
56
|
+
applications: applications(row['application_list']),
|
|
57
|
+
crush_rule: text(row['crush_rule']),
|
|
58
|
+
id: int(row['id'], UNSET),
|
|
59
|
+
min_size: int(row['min_size'], UNSET),
|
|
60
|
+
name: props.name,
|
|
61
|
+
node: props.node,
|
|
62
|
+
nodelete: bool(row['nodelete']),
|
|
63
|
+
nopgchange: bool(row['nopgchange']),
|
|
64
|
+
nosizechange: bool(row['nosizechange']),
|
|
65
|
+
pg_autoscale_mode: text(row['pg_autoscale_mode']),
|
|
66
|
+
pg_num: int(row['pg_num'], UNSET),
|
|
67
|
+
pg_num_min: int(row['pg_num_min'], UNSET),
|
|
68
|
+
size: int(row['size'], UNSET),
|
|
69
|
+
target_size: int(row['target_size'], UNSET),
|
|
70
|
+
target_size_ratio: num(row['target_size_ratio'], UNSET),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The live pool, or `undefined` — folds every failure, exactly as `ops.read` did. Used by `read`,
|
|
76
|
+
* `diff` (via `ceph-pool.ts`'s own hand-written `reconcile`, which predates the factory) and
|
|
77
|
+
* `settle` (ceph-pool-settle.ts) alike, matching the ONE-function shape this family had before.
|
|
78
|
+
*
|
|
79
|
+
* ⛔ `verbose=1` IS WHAT MAKES `applications` VISIBLE AT ALL — ceph-pool.ts's own header has the
|
|
80
|
+
* measured reason and the cost (two extra, unguarded mon commands PVE runs to answer it).
|
|
81
|
+
*/
|
|
82
|
+
export const readPoolStatus = (props: CephPoolProps) =>
|
|
83
|
+
runPve(
|
|
84
|
+
props.target,
|
|
85
|
+
'read',
|
|
86
|
+
false,
|
|
87
|
+
nodes.getNodeCephPoolStatus({ name: props.name, node: props.node, verbose: '1' }),
|
|
88
|
+
).pipe(
|
|
89
|
+
Effect.map((live) => attributesOf(live, props)),
|
|
90
|
+
Effect.orElseSucceed(() => undefined),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/** The two endpoints, as the keys `guardWrite` (distilled-guard.ts) checks both forms against. */
|
|
94
|
+
export const CEPH_POOL_CREATE = 'pve:POST /nodes/{node}/ceph/pool';
|
|
95
|
+
export const CEPH_POOL_UPDATE = 'pve:PUT /nodes/{node}/ceph/pool/{name}';
|
|
96
|
+
|
|
97
|
+
/** The actual `createNodeCephPool` call body — `createBody`, translated. No field is renamed. */
|
|
98
|
+
export const toDistilledCreate = (props: CephPoolProps): nodes.CreateNodeCephPoolRequest =>
|
|
99
|
+
({
|
|
100
|
+
...createBody(props),
|
|
101
|
+
node: props.node,
|
|
102
|
+
}) as unknown as nodes.CreateNodeCephPoolRequest;
|
|
103
|
+
|
|
104
|
+
/** The actual `putNodeCephPool` call body — `updateBody`, translated. No field is renamed. */
|
|
105
|
+
export const toDistilledUpdate = (props: CephPoolProps): nodes.PutNodeCephPoolRequest =>
|
|
106
|
+
({
|
|
107
|
+
...updateBody(props),
|
|
108
|
+
name: props.name,
|
|
109
|
+
node: props.node,
|
|
110
|
+
}) as unknown as nodes.PutNodeCephPoolRequest;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephPool`'s write paths, through Alchemy's own Plan and Apply over a fake cluster —
|
|
3
|
+
* the paths ceph-pool-adopt.test.ts does not reach: a genuine create, the PG-merge guard that
|
|
4
|
+
* stops a create over a pool the read could not confirm, and a real destroy.
|
|
5
|
+
*
|
|
6
|
+
* ★ THE ABSENCE SIGNAL IS THE MEASURED ONE. `GET .../pool/{name}/status` on a name with no pool
|
|
7
|
+
* answers HTTP 500 (live TB4 `n2`, 2026-09-24, read-role, read-only probe:
|
|
8
|
+
* `{"data":null,"message":"error with 'osd pool get': mon_cmd failed - unrecognized pool
|
|
9
|
+
* '<name>'\n"}`) — a generic failure, not a parsed field like `Proxmox.NodeNetwork`'s 400. That
|
|
10
|
+
* is why `readPoolStatus` (ceph-pool-wire.ts) folds every failure the same way `ops.read` always
|
|
11
|
+
* did, and why `confirmAbsent` (ceph-pool-settle.ts) asks a SECOND, different question — does
|
|
12
|
+
* the index list this name — before a create is allowed to run. `fakePve` (fake-pve.ts) cannot
|
|
13
|
+
* answer anything but HTTP 200, so this file builds its own stub the way
|
|
14
|
+
* zfs-pool-read-failure.test.ts does, to answer the measured 500 directly.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import * as Layer from 'effect/Layer';
|
|
20
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
21
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
22
|
+
import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
|
|
23
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
24
|
+
|
|
25
|
+
const NODE = 'node-b';
|
|
26
|
+
const NAME = 'newpool';
|
|
27
|
+
const INDEX = `nodes/${NODE}/ceph/pool`;
|
|
28
|
+
const OBJECT = `${INDEX}/${NAME}`;
|
|
29
|
+
const STATUS = `${OBJECT}/status`;
|
|
30
|
+
|
|
31
|
+
/** A generic 500 — the measured absence shape, indistinguishable from any other server error. */
|
|
32
|
+
const absent = () =>
|
|
33
|
+
Response.json(
|
|
34
|
+
{ data: null, message: `error with 'osd pool get': mon_cmd failed - unrecognized pool` },
|
|
35
|
+
{ status: 500 },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A cluster the test drives through three states: no pool at all, the pool listed but its status
|
|
40
|
+
* unreadable (the PG-merge guard's own case), and a real pool. `writes` records every non-GET
|
|
41
|
+
* call the same way `fakePve.writes()` does, for the same assertions.
|
|
42
|
+
*/
|
|
43
|
+
const cluster = () => {
|
|
44
|
+
const writes: string[] = [];
|
|
45
|
+
let indexRow: Record<string, unknown> | undefined;
|
|
46
|
+
let statusRow: Record<string, unknown> | undefined;
|
|
47
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
48
|
+
const request =
|
|
49
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
50
|
+
const url = new URL(request.url);
|
|
51
|
+
if (url.pathname.includes('/creds/')) {
|
|
52
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
53
|
+
return Response.json({ data, lease_duration: 0 });
|
|
54
|
+
}
|
|
55
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
56
|
+
if (request.method !== 'GET') writes.push(`${request.method} ${wire}`);
|
|
57
|
+
if (wire === INDEX && request.method === 'GET') {
|
|
58
|
+
return Response.json({ data: indexRow === undefined ? [] : [indexRow] });
|
|
59
|
+
}
|
|
60
|
+
if (wire === INDEX && request.method === 'POST') {
|
|
61
|
+
indexRow = { pool_name: NAME };
|
|
62
|
+
statusRow = {
|
|
63
|
+
application_list: [],
|
|
64
|
+
crush_rule: 'replicated_rule',
|
|
65
|
+
id: 9,
|
|
66
|
+
min_size: 2,
|
|
67
|
+
name: NAME,
|
|
68
|
+
pg_autoscale_mode: 'on',
|
|
69
|
+
pg_num: 128,
|
|
70
|
+
size: 3,
|
|
71
|
+
};
|
|
72
|
+
return Response.json({ data: 'UPID:node-b:fake:createpool' });
|
|
73
|
+
}
|
|
74
|
+
if (wire === STATUS && request.method === 'GET') {
|
|
75
|
+
return statusRow === undefined ? absent() : Response.json({ data: statusRow });
|
|
76
|
+
}
|
|
77
|
+
if (wire === OBJECT && request.method === 'DELETE') {
|
|
78
|
+
indexRow = undefined;
|
|
79
|
+
statusRow = undefined;
|
|
80
|
+
return Response.json({ data: 'UPID:node-b:fake:destroypool' });
|
|
81
|
+
}
|
|
82
|
+
return Response.json({ data: null });
|
|
83
|
+
};
|
|
84
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
85
|
+
return {
|
|
86
|
+
layer: FetchHttpClient.layer.pipe(
|
|
87
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
88
|
+
),
|
|
89
|
+
listAsIfPresent: () => {
|
|
90
|
+
indexRow = { pool_name: NAME };
|
|
91
|
+
},
|
|
92
|
+
writes,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const declared = () =>
|
|
97
|
+
ProxmoxCephPool(NAME, {
|
|
98
|
+
crush_rule: 'replicated_rule',
|
|
99
|
+
min_size: 2,
|
|
100
|
+
name: NAME,
|
|
101
|
+
node: NODE,
|
|
102
|
+
pg_autoscale_mode: 'on',
|
|
103
|
+
size: 3,
|
|
104
|
+
target: FAKE_TARGET,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
108
|
+
engineOver(ProxmoxCephPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
109
|
+
|
|
110
|
+
describe('a genuinely new pool', () => {
|
|
111
|
+
test('creates once confirmAbsent finds the index empty, and settle confirms the match', async () => {
|
|
112
|
+
const fake = cluster();
|
|
113
|
+
await withoutBao(async () => {
|
|
114
|
+
const engine = engineFor(fake);
|
|
115
|
+
expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
|
|
116
|
+
});
|
|
117
|
+
expect(fake.writes).toEqual([`POST ${INDEX}`]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("the PG-merge guard: the index lists the pool but its status can't be read", () => {
|
|
122
|
+
test('refuses the create rather than risking a PG merge on a live pool', async () => {
|
|
123
|
+
const fake = cluster();
|
|
124
|
+
fake.listAsIfPresent(); // index says present; statusRow stays undefined -> every read 500s
|
|
125
|
+
await withoutBao(async () => {
|
|
126
|
+
const engine = engineFor(fake);
|
|
127
|
+
await expect(engine.deploy(declared())).rejects.toBeDefined();
|
|
128
|
+
});
|
|
129
|
+
// ⛔ THE ASSERTION THAT MATTERS: no POST ever reached the fake cluster.
|
|
130
|
+
expect(fake.writes).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('destroying a pool', () => {
|
|
135
|
+
test('RemovalPolicy.destroy() then undeclaring sends exactly one DELETE', async () => {
|
|
136
|
+
const fake = cluster();
|
|
137
|
+
await withoutBao(async () => {
|
|
138
|
+
const engine = engineFor(fake);
|
|
139
|
+
expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
|
|
140
|
+
await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
|
|
141
|
+
await engine.deploy(Effect.void);
|
|
142
|
+
});
|
|
143
|
+
expect(fake.writes).toEqual([`POST ${INDEX}`, `DELETE ${OBJECT}`]);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/proxmox/ceph-pool.ts
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
* ⛔ IT DOES NOT USE `pveHandlers`, AND THE REASON IS MEASURED RATHER THAN STYLISTIC: THE READ AND
|
|
6
6
|
* THE WRITES SIT AT DIFFERENT PATHS. `GET /nodes/{node}/ceph/pool/{name}` is `poolindex` and
|
|
7
7
|
* answers `[{"name":"status"}]` — an INDEX OF CHILDREN, not the pool. The pool is one level
|
|
8
|
-
* further down, at `.../status` (`getpool`), which accepts no PUT and no DELETE.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* further down, at `.../status` (`getpool`), which accepts no PUT and no DELETE. Every handler
|
|
9
|
+
* below is hand-written for that reason, on `client.ts` and now on distilled alike — acl.ts is
|
|
10
|
+
* the precedent for hand-writing over the factory.
|
|
11
|
+
*
|
|
12
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
|
|
13
|
+
* `nodes.getNodeCephPoolStatus`/`createNodeCephPool`/`putNodeCephPool`/`deleteNodeCephPool`/
|
|
14
|
+
* `listNodeCephPool` (2026-09-24, decision 43's walk-down, 2c). `distilled-pve.ts`'s `runPve`
|
|
15
|
+
* replaces `pve()`; `readPoolStatus` (ceph-pool-wire.ts) keeps the SAME single-fold shape this
|
|
16
|
+
* family always had — see that file's own header for why this migration does not also add the
|
|
17
|
+
* newer dual-path pattern `Proxmox.NodeNetwork` carries.
|
|
13
18
|
*
|
|
14
19
|
* ⛔ A CREATE FIRED AT A POOL THAT ALREADY EXISTS IS NOT A NO-OP, IT IS A PG MERGE — the second ⛔
|
|
15
20
|
* in ceph-pool-form.ts has the evidence, and `confirmAbsent` below is the guard that stops it.
|
|
@@ -36,14 +41,13 @@
|
|
|
36
41
|
* deliberately, the way `Pool.Allocate` was widened for `Proxmox.Pool`.
|
|
37
42
|
*/
|
|
38
43
|
import { Resource } from 'alchemy';
|
|
44
|
+
import { isResolved } from 'alchemy/Diff';
|
|
39
45
|
import * as Provider from 'alchemy/Provider';
|
|
46
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
40
47
|
import * as Effect from 'effect/Effect';
|
|
41
48
|
import {
|
|
42
49
|
type CephPoolAttributes,
|
|
43
50
|
type CephPoolProps,
|
|
44
|
-
UNSET,
|
|
45
|
-
applications,
|
|
46
|
-
collection,
|
|
47
51
|
createBody,
|
|
48
52
|
hint,
|
|
49
53
|
object,
|
|
@@ -51,10 +55,17 @@ import {
|
|
|
51
55
|
updateBody,
|
|
52
56
|
} from './ceph-pool-form.ts';
|
|
53
57
|
import { confirmAbsent, settle } from './ceph-pool-settle.ts';
|
|
54
|
-
import {
|
|
55
|
-
|
|
58
|
+
import {
|
|
59
|
+
CEPH_POOL_CREATE,
|
|
60
|
+
CEPH_POOL_UPDATE,
|
|
61
|
+
readPoolStatus,
|
|
62
|
+
toDistilledCreate,
|
|
63
|
+
toDistilledUpdate,
|
|
64
|
+
} from './ceph-pool-wire.ts';
|
|
65
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
66
|
+
import { runPve } from './distilled-pve.ts';
|
|
67
|
+
import { type PveRequirements } from './resource.ts';
|
|
56
68
|
import { formToSend } from './update-guard.ts';
|
|
57
|
-
import { bool, int, num, text } from './values.ts';
|
|
58
69
|
|
|
59
70
|
export type { CephPoolAttributes, CephPoolProps };
|
|
60
71
|
|
|
@@ -71,73 +82,24 @@ export const ProxmoxCephPool = Resource<ProxmoxCephPool>('Proxmox.CephPool', {
|
|
|
71
82
|
defaultRemovalPolicy: 'retain',
|
|
72
83
|
});
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
nopgchange: bool(live['nopgchange']),
|
|
93
|
-
nosizechange: bool(live['nosizechange']),
|
|
94
|
-
pg_autoscale_mode: text(live['pg_autoscale_mode']),
|
|
95
|
-
pg_num: int(live['pg_num'], UNSET),
|
|
96
|
-
pg_num_min: int(live['pg_num_min'], UNSET),
|
|
97
|
-
size: int(live['size'], UNSET),
|
|
98
|
-
target_size: int(live['target_size'], UNSET),
|
|
99
|
-
target_size_ratio: num(live['target_size_ratio'], UNSET),
|
|
100
|
-
};
|
|
101
|
-
},
|
|
102
|
-
collection,
|
|
103
|
-
createForm: createBody,
|
|
104
|
-
/** The vendor rules both forms are checked against at plan time — resource-spec.ts. */
|
|
105
|
-
endpoint: {
|
|
106
|
-
create: 'pve:POST /nodes/{node}/ceph/pool',
|
|
107
|
-
update: 'pve:PUT /nodes/{node}/ceph/pool/{name}',
|
|
108
|
-
},
|
|
109
|
-
/**
|
|
110
|
-
* ⛔ DECLARING WHAT IS LIVE MUST PLAN noop, AND EVERY OMISSION HERE IS WHY. Out, each with its
|
|
111
|
-
* reason on the prop it belongs to: `pg_num` (the autoscaler rewrites it), `application` (the
|
|
112
|
-
* write only ever ADDS to a list), `target_size_ratio` (float equality), `id`, `applications`
|
|
113
|
-
* and the three `no*` flags (PVE returns them and accepts none of them on write), `node` and
|
|
114
|
-
* `name` (the address the read was made at — true by construction, never a diff). In, and each
|
|
115
|
-
* measured to round-trip unchanged against C1's four pools on 2026-09-13: size 3, min_size 2,
|
|
116
|
-
* pg_autoscale_mode `on`, crush_rule `replicated_rule`, pg_num_min where it is set (16 on
|
|
117
|
-
* cephfs-c1_metadata, absent on rbd-c1), target_size in bytes — and each of the last two
|
|
118
|
-
* through `hint`, which drops a declared zero for the reason given on it.
|
|
119
|
-
*/
|
|
120
|
-
matches: (attributes, props) =>
|
|
121
|
-
same(props.size, attributes.size, attributes.nosizechange) &&
|
|
122
|
-
same(props.min_size, attributes.min_size, attributes.nosizechange) &&
|
|
123
|
-
same(props.pg_autoscale_mode, attributes.pg_autoscale_mode) &&
|
|
124
|
-
same(hint(props.pg_num_min), attributes.pg_num_min, attributes.nopgchange) &&
|
|
125
|
-
same(props.crush_rule, attributes.crush_rule, attributes.crush_rule === '') &&
|
|
126
|
-
same(hint(props.target_size), attributes.target_size),
|
|
127
|
-
/**
|
|
128
|
-
* ⛔ THE READ PATH, AND ONLY THE READ PATH.
|
|
129
|
-
* ⚠️ `?verbose=1` IS WHAT MAKES `applications` VISIBLE AT ALL — without it PVE omits the tags
|
|
130
|
-
* entirely (MEASURED across all four pools; there is no `application` key either way, only
|
|
131
|
-
* `application_list`, and only when verbose). The cost is that verbose adds two unguarded mon
|
|
132
|
-
* commands, `df` and `osd pool application get`, so a degraded mgr can fail the read. That is
|
|
133
|
-
* safe here rather than merely unlucky: a failed read is "absent", and "absent" cannot reach a
|
|
134
|
-
* create unless `confirmAbsent` agrees.
|
|
135
|
-
*/
|
|
136
|
-
path: (props) => `${object(props)}/status?verbose=1`,
|
|
137
|
-
updateForm: updateBody,
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const ops = pveOperations(spec);
|
|
85
|
+
/**
|
|
86
|
+
* ⛔ DECLARING WHAT IS LIVE MUST PLAN noop, AND EVERY OMISSION HERE IS WHY. Out, each with its
|
|
87
|
+
* reason on the prop it belongs to: `pg_num` (the autoscaler rewrites it), `application` (the
|
|
88
|
+
* write only ever ADDS to a list), `target_size_ratio` (float equality), `id`, `applications`
|
|
89
|
+
* and the three `no*` flags (PVE returns them and accepts none of them on write), `node` and
|
|
90
|
+
* `name` (the address the read was made at — true by construction, never a diff). In, and each
|
|
91
|
+
* measured to round-trip unchanged against C1's four pools on 2026-09-13: size 3, min_size 2,
|
|
92
|
+
* pg_autoscale_mode `on`, crush_rule `replicated_rule`, pg_num_min where it is set (16 on
|
|
93
|
+
* cephfs-c1_metadata, absent on rbd-c1), target_size in bytes — and each of the last two
|
|
94
|
+
* through `hint`, which drops a declared zero for the reason given on it.
|
|
95
|
+
*/
|
|
96
|
+
const matches = (attributes: CephPoolAttributes, props: CephPoolProps) =>
|
|
97
|
+
same(props.size, attributes.size, attributes.nosizechange) &&
|
|
98
|
+
same(props.min_size, attributes.min_size, attributes.nosizechange) &&
|
|
99
|
+
same(props.pg_autoscale_mode, attributes.pg_autoscale_mode) &&
|
|
100
|
+
same(hint(props.pg_num_min), attributes.pg_num_min, attributes.nopgchange) &&
|
|
101
|
+
same(props.crush_rule, attributes.crush_rule, attributes.crush_rule === '') &&
|
|
102
|
+
same(hint(props.target_size), attributes.target_size);
|
|
141
103
|
|
|
142
104
|
export const ProxmoxCephPoolProvider = () =>
|
|
143
105
|
Provider.effect(
|
|
@@ -146,28 +108,45 @@ export const ProxmoxCephPoolProvider = () =>
|
|
|
146
108
|
ProxmoxCephPool.Provider.of({
|
|
147
109
|
/** ⛔ Empty, and here it is what keeps `.mgr` and the two cephfs pools out — see the header. */
|
|
148
110
|
list: () => Effect.succeed([]),
|
|
149
|
-
read: ({ olds }) =>
|
|
150
|
-
diff: ({ news, output }) => ops.diff(news, output),
|
|
111
|
+
read: ({ olds }) => readPoolStatus(olds),
|
|
151
112
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
113
|
+
* ★ HAND-WRITTEN, matching the pre-migration `ops.diff` shape exactly (resource.ts) — this
|
|
114
|
+
* family never used the factory's own `diff`/`reconcile` (only its `read`), and the
|
|
115
|
+
* factory itself cannot run a distilled operation, so both are inlined here now.
|
|
116
|
+
*/
|
|
117
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
118
|
+
if (!isResolved(news)) return undefined;
|
|
119
|
+
yield* guardWrite(CEPH_POOL_CREATE, createBody(news), output === undefined);
|
|
120
|
+
yield* guardWrite(CEPH_POOL_UPDATE, updateBody(news), false);
|
|
121
|
+
if (output === undefined) return undefined;
|
|
122
|
+
const live = yield* readPoolStatus(news);
|
|
123
|
+
if (live === undefined) {
|
|
124
|
+
yield* guardWrite(CEPH_POOL_CREATE, createBody(news), true);
|
|
125
|
+
return { action: 'update' } as const;
|
|
126
|
+
}
|
|
127
|
+
return matches(live, news)
|
|
128
|
+
? ({ action: 'noop' } as const)
|
|
129
|
+
: ({ action: 'update' } as const);
|
|
130
|
+
}),
|
|
131
|
+
/**
|
|
132
|
+
* ⚠️ NOT A GENERIC reconcile: it POSTs the moment a read comes back empty, and reads back
|
|
133
|
+
* once, immediately. Both are wrong for a family whose writes are forked workers and whose
|
|
154
134
|
* create is destructive against a pool that is already there.
|
|
155
135
|
*/
|
|
156
136
|
reconcile: Effect.fn(function* ({ news }) {
|
|
157
|
-
const live = yield*
|
|
137
|
+
const live = yield* readPoolStatus(news);
|
|
158
138
|
let upid: string | undefined;
|
|
159
139
|
if (live === undefined) {
|
|
160
|
-
// ⛔ THE GUARD
|
|
140
|
+
// ⛔ THE GUARD A GENERIC reconcile WOULD HAVE RUN, RESTORED. This handler replaces it
|
|
161
141
|
// wholesale, so without these two calls an ADOPTED pool — whose diff answer Alchemy
|
|
162
142
|
// discards — would reach the cluster with nothing having checked its body.
|
|
163
|
-
yield*
|
|
164
|
-
yield* confirmAbsent(news,
|
|
165
|
-
upid = yield*
|
|
143
|
+
yield* guardWrite(CEPH_POOL_CREATE, createBody(news), true);
|
|
144
|
+
yield* confirmAbsent(news, object(news));
|
|
145
|
+
upid = yield* runPve(
|
|
166
146
|
news.target,
|
|
167
147
|
'provision',
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
createBody(news),
|
|
148
|
+
true,
|
|
149
|
+
nodes.createNodeCephPool(toDistilledCreate(news)),
|
|
171
150
|
);
|
|
172
151
|
} else {
|
|
173
152
|
/**
|
|
@@ -177,18 +156,23 @@ export const ProxmoxCephPoolProvider = () =>
|
|
|
177
156
|
* settings (ceph-pool-form.ts) made it harmless to Ceph, not free: it is still a
|
|
178
157
|
* provision-lease write and a task in the cluster's list. update-guard.ts has the rest.
|
|
179
158
|
*/
|
|
180
|
-
const form = formToSend(
|
|
159
|
+
const form = formToSend(matches, live, news, updateBody(news));
|
|
181
160
|
if (form !== undefined) {
|
|
182
|
-
yield*
|
|
183
|
-
upid = yield*
|
|
161
|
+
yield* guardWrite(CEPH_POOL_UPDATE, updateBody(news), false);
|
|
162
|
+
upid = yield* runPve(
|
|
163
|
+
news.target,
|
|
164
|
+
'provision',
|
|
165
|
+
true,
|
|
166
|
+
nodes.putNodeCephPool(toDistilledUpdate(news)),
|
|
167
|
+
);
|
|
184
168
|
}
|
|
185
169
|
}
|
|
186
170
|
const after = yield* settle(
|
|
187
171
|
news,
|
|
188
|
-
|
|
189
|
-
(row) => row !== undefined &&
|
|
172
|
+
readPoolStatus,
|
|
173
|
+
(row) => row !== undefined && matches(row, news),
|
|
190
174
|
);
|
|
191
|
-
if (after === undefined || !
|
|
175
|
+
if (after === undefined || !matches(after, news)) {
|
|
192
176
|
return yield* Effect.die(
|
|
193
177
|
new Error(
|
|
194
178
|
`${object(news)}: the write returned no error but the pool still does not match ` +
|
|
@@ -212,8 +196,13 @@ export const ProxmoxCephPoolProvider = () =>
|
|
|
212
196
|
* set in ceph.conf and the two sources disagree by design.
|
|
213
197
|
*/
|
|
214
198
|
delete: Effect.fn(function* ({ olds }) {
|
|
215
|
-
yield*
|
|
216
|
-
|
|
199
|
+
yield* runPve(
|
|
200
|
+
olds.target,
|
|
201
|
+
'provision',
|
|
202
|
+
true,
|
|
203
|
+
nodes.deleteNodeCephPool({ name: olds.name, node: olds.node }),
|
|
204
|
+
);
|
|
205
|
+
const left = yield* settle(olds, readPoolStatus, (row) => row === undefined);
|
|
217
206
|
if (left !== undefined) {
|
|
218
207
|
return yield* Effect.die(
|
|
219
208
|
new Error(
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Exercises `@distilled.cloud/proxmox`'s `awaitTask` (the async-task
|
|
3
|
-
* 200-trap — see that package's `src/protocol.ts` header) against a FAKE
|
|
4
|
-
* `HttpClient`, no live PVE node involved.
|
|
5
|
-
*
|
|
6
|
-
* ⚠️ THIS PACKAGE IS CONSUMED VIA THE INTERIM-PACKAGE ALIAS
|
|
7
|
-
* (`packages/alchemy/package.json`'s `dependencies`:
|
|
8
|
-
* `"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0"`)
|
|
9
|
-
* — see `packages/alchemy/docs/distilled-interim.md`. Nothing in
|
|
10
|
-
* `packages/alchemy/src/proxmox/*` (the existing 98-resource family) is
|
|
11
|
-
* touched or migrated onto it here; this file is standalone proof the
|
|
12
|
-
* package works end to end, ahead of migrating any hand-written resource
|
|
13
|
-
* onto it (a later PR).
|
|
14
|
-
*/
|
|
15
|
-
import { describe, expect, test } from 'bun:test';
|
|
16
|
-
import * as Effect from 'effect/Effect';
|
|
17
|
-
import * as Layer from 'effect/Layer';
|
|
18
|
-
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
-
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
20
|
-
import * as Proxmox from '@distilled.cloud/proxmox';
|
|
21
|
-
|
|
22
|
-
const credentials = Proxmox.credentials({
|
|
23
|
-
tokenId: 'root@pam!test',
|
|
24
|
-
secret: 'test-secret',
|
|
25
|
-
baseUrl: 'https://pve.test:8006',
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
/** A fake PVE that answers `running` (no `exitstatus`) `pendingPolls` times, then `finalBody`. */
|
|
29
|
-
const fakePve = (pendingPolls: number, finalBody: Record<string, unknown>) => {
|
|
30
|
-
let calls = 0;
|
|
31
|
-
return Layer.succeed(
|
|
32
|
-
HttpClient.HttpClient,
|
|
33
|
-
HttpClient.make((request) =>
|
|
34
|
-
Effect.sync(() => {
|
|
35
|
-
calls++;
|
|
36
|
-
const body =
|
|
37
|
-
calls <= pendingPolls
|
|
38
|
-
? {
|
|
39
|
-
status: 'running',
|
|
40
|
-
id: 'qmstart',
|
|
41
|
-
node: 'pve1',
|
|
42
|
-
pid: 100,
|
|
43
|
-
pstart: 1,
|
|
44
|
-
starttime: 1,
|
|
45
|
-
type: 'qmstart',
|
|
46
|
-
upid: 'UPID:pve1:...',
|
|
47
|
-
user: 'root@pam',
|
|
48
|
-
}
|
|
49
|
-
: finalBody;
|
|
50
|
-
return HttpClientResponse.fromWeb(
|
|
51
|
-
request,
|
|
52
|
-
new Response(JSON.stringify({ data: body }), { status: 200 }),
|
|
53
|
-
);
|
|
54
|
-
}),
|
|
55
|
-
),
|
|
56
|
-
);
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const runAwaitTask = (pendingPolls: number, finalBody: Record<string, unknown>) =>
|
|
60
|
-
Effect.runPromise(
|
|
61
|
-
Proxmox.awaitTask(
|
|
62
|
-
{ node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
|
|
63
|
-
{ pollInterval: '5 millis' },
|
|
64
|
-
).pipe(Effect.provide(Layer.mergeAll(fakePve(pendingPolls, finalBody), credentials))),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const OK_STATUS = {
|
|
68
|
-
status: 'stopped',
|
|
69
|
-
exitstatus: 'OK',
|
|
70
|
-
id: 'qmstart',
|
|
71
|
-
node: 'pve1',
|
|
72
|
-
pid: 100,
|
|
73
|
-
pstart: 1,
|
|
74
|
-
starttime: 1,
|
|
75
|
-
type: 'qmstart',
|
|
76
|
-
upid: 'UPID:pve1:...',
|
|
77
|
-
user: 'root@pam',
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
describe("awaitTask (against a fake PVE, per protocol.ts's async-task trap)", () => {
|
|
81
|
-
test("resolves once exitstatus is exactly OK, after polling through 'running'", async () => {
|
|
82
|
-
const result = await runAwaitTask(2, OK_STATUS);
|
|
83
|
-
expect(result.exitstatus).toBe('OK');
|
|
84
|
-
expect(result.status).toBe('stopped');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test('resolves immediately when the task is already done on the first poll', async () => {
|
|
88
|
-
const result = await runAwaitTask(0, OK_STATUS);
|
|
89
|
-
expect(result.exitstatus).toBe('OK');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('fails with ProxmoxTaskFailed for any exitstatus other than exactly OK', async () => {
|
|
93
|
-
const failed = await Effect.runPromise(
|
|
94
|
-
Proxmox.awaitTask(
|
|
95
|
-
{ node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
|
|
96
|
-
{ pollInterval: '5 millis' },
|
|
97
|
-
).pipe(
|
|
98
|
-
Effect.provide(
|
|
99
|
-
Layer.mergeAll(fakePve(0, { ...OK_STATUS, exitstatus: 'job errors' }), credentials),
|
|
100
|
-
),
|
|
101
|
-
Effect.flip,
|
|
102
|
-
),
|
|
103
|
-
);
|
|
104
|
-
expect(failed).toBeInstanceOf(Proxmox.ProxmoxTaskFailed);
|
|
105
|
-
expect(failed).toMatchObject({ node: 'pve1', exitstatus: 'job errors' });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// ⛔ "OK (warnings)" is NOT a success — see task.ts's exact-equality note.
|
|
109
|
-
test("fails (does not swallow) 'OK (warnings)'", async () => {
|
|
110
|
-
const failed = await Effect.runPromise(
|
|
111
|
-
Proxmox.awaitTask(
|
|
112
|
-
{ node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
|
|
113
|
-
{ pollInterval: '5 millis' },
|
|
114
|
-
).pipe(
|
|
115
|
-
Effect.provide(
|
|
116
|
-
Layer.mergeAll(fakePve(0, { ...OK_STATUS, exitstatus: 'OK (warnings)' }), credentials),
|
|
117
|
-
),
|
|
118
|
-
Effect.flip,
|
|
119
|
-
),
|
|
120
|
-
);
|
|
121
|
-
expect(failed).toBeInstanceOf(Proxmox.ProxmoxTaskFailed);
|
|
122
|
-
expect(failed).toMatchObject({ exitstatus: 'OK (warnings)' });
|
|
123
|
-
});
|
|
124
|
-
});
|