@homeflare/alchemy 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/argocd/cluster.d.ts +102 -0
- package/dist/argocd/cluster.d.ts.map +1 -0
- package/dist/argocd/credentials.d.ts +37 -0
- package/dist/argocd/credentials.d.ts.map +1 -0
- package/dist/argocd/fake-argocd.d.ts +42 -0
- package/dist/argocd/fake-argocd.d.ts.map +1 -0
- package/dist/argocd/git-credentials.d.ts +29 -0
- package/dist/argocd/git-credentials.d.ts.map +1 -0
- package/dist/argocd/index.d.ts +19 -0
- package/dist/argocd/index.d.ts.map +1 -0
- package/dist/argocd/index.js +306 -0
- package/dist/argocd/index.js.map +17 -0
- package/dist/argocd/providers.d.ts +13 -0
- package/dist/argocd/providers.d.ts.map +1 -0
- package/dist/argocd/repo-creds.d.ts +67 -0
- package/dist/argocd/repo-creds.d.ts.map +1 -0
- package/dist/argocd/repository.d.ts +87 -0
- package/dist/argocd/repository.d.ts.map +1 -0
- package/dist/argocd/resource.d.ts +90 -0
- package/dist/argocd/resource.d.ts.map +1 -0
- package/dist/cloudflare/index.js +4 -4
- package/dist/cloudflare/index.js.map +3 -3
- package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/litellm/index.js.map +2 -2
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-wire.d.ts +31 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +40 -6
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +33 -0
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +30 -6
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +17 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd.d.ts +6 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +19 -12
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +37 -0
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +10 -5
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +8 -13
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +605 -483
- package/dist/proxmox/index.js.map +20 -17
- package/dist/proxmox/storage-wire.d.ts +15 -11
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +7 -7
- package/dist/proxmox/user-wire.d.ts +8 -11
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +5 -5
- package/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/distilled-interim.md +15 -3
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/provider-standard.md +24 -6
- package/docs/proxmox.md +20 -0
- package/docs/unifi.md +45 -0
- package/docs/upstream-conformance-bun.md +67 -0
- package/docs/upstream-conformance-conforms.md +55 -0
- package/docs/upstream-conformance-families.md +95 -0
- package/docs/upstream-conformance-grafana.md +106 -0
- package/docs/upstream-conformance.md +20 -298
- package/package.json +8 -3
- package/src/argocd/cluster.test.ts +120 -0
- package/src/argocd/cluster.ts +222 -0
- package/src/argocd/credentials.ts +38 -0
- package/src/argocd/fake-argocd.ts +60 -0
- package/src/argocd/git-credentials.ts +71 -0
- package/src/argocd/index.ts +40 -0
- package/src/argocd/providers.ts +17 -0
- package/src/argocd/repo-creds.test.ts +123 -0
- package/src/argocd/repo-creds.ts +115 -0
- package/src/argocd/repository.test.ts +167 -0
- package/src/argocd/repository.ts +165 -0
- package/src/argocd/resource.ts +150 -0
- package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
- package/src/cloudflare/r2-bucket-lock.ts +25 -18
- package/src/litellm/operations.ts +8 -4
- package/src/proxmox/ceph-daemon-form.ts +7 -8
- package/src/proxmox/ceph-daemon-wire.ts +112 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +70 -53
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +100 -0
- package/src/proxmox/ceph-fs-wire.ts +17 -31
- package/src/proxmox/ceph-fs-write.test.ts +67 -0
- package/src/proxmox/ceph-fs.ts +68 -73
- package/src/proxmox/ceph-osd-tree.ts +14 -5
- package/src/proxmox/ceph-osd-write.test.ts +61 -0
- package/src/proxmox/ceph-osd-write.ts +33 -6
- package/src/proxmox/ceph-osd.ts +6 -0
- package/src/proxmox/ceph-pool-settle.ts +24 -17
- package/src/proxmox/ceph-pool-wire.ts +101 -0
- package/src/proxmox/ceph-pool-write.test.ts +146 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- package/src/proxmox/group-wire.ts +7 -12
- package/src/proxmox/read-failure-propagation.test.ts +5 -5
- package/src/proxmox/storage-wire.ts +9 -10
- package/src/proxmox/typed-absence.test.ts +146 -0
- package/src/proxmox/unreadable-read.ts +7 -7
- package/src/proxmox/user-wire.ts +7 -10
- package/src/proxmox/user.ts +5 -5
- package/src/proxmox/distilled-task-await.test.ts +0 -124
|
@@ -0,0 +1,146 @@
|
|
|
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"}`) — SDK PR #265 now recognizes it as `CephPoolNotFound`. Only that tag folds
|
|
10
|
+
* to absence; unrelated 500s propagate. `confirmAbsent` (ceph-pool-settle.ts) still asks a
|
|
11
|
+
* SECOND, different question — does the index list this name — before create may run.
|
|
12
|
+
* `fakePve` only answers HTTP 200, so this file stubs the measured 500 directly.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import * as Layer from 'effect/Layer';
|
|
18
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
19
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
20
|
+
import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
|
|
21
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
22
|
+
|
|
23
|
+
const NODE = 'node-b';
|
|
24
|
+
const NAME = 'newpool';
|
|
25
|
+
const INDEX = `nodes/${NODE}/ceph/pool`;
|
|
26
|
+
const OBJECT = `${INDEX}/${NAME}`;
|
|
27
|
+
const STATUS = `${OBJECT}/status`;
|
|
28
|
+
|
|
29
|
+
/** The measured missing-pool 500, classified by the SDK as `CephPoolNotFound`. */
|
|
30
|
+
const absent = () =>
|
|
31
|
+
Response.json(
|
|
32
|
+
{
|
|
33
|
+
data: null,
|
|
34
|
+
message: `error with 'osd pool get': mon_cmd failed - unrecognized pool '${NAME}'\n`,
|
|
35
|
+
},
|
|
36
|
+
{ status: 500 },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A cluster the test drives through three states: no pool at all, the pool listed but its status
|
|
41
|
+
* unreadable (the PG-merge guard's own case), and a real pool. `writes` records every non-GET
|
|
42
|
+
* call the same way `fakePve.writes()` does, for the same assertions.
|
|
43
|
+
*/
|
|
44
|
+
const cluster = () => {
|
|
45
|
+
const writes: string[] = [];
|
|
46
|
+
let indexRow: Record<string, unknown> | undefined;
|
|
47
|
+
let statusRow: Record<string, unknown> | undefined;
|
|
48
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
49
|
+
const request =
|
|
50
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
if (url.pathname.includes('/creds/')) {
|
|
53
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
54
|
+
return Response.json({ data, lease_duration: 0 });
|
|
55
|
+
}
|
|
56
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
57
|
+
if (request.method !== 'GET') writes.push(`${request.method} ${wire}`);
|
|
58
|
+
if (wire === INDEX && request.method === 'GET') {
|
|
59
|
+
return Response.json({ data: indexRow === undefined ? [] : [indexRow] });
|
|
60
|
+
}
|
|
61
|
+
if (wire === INDEX && request.method === 'POST') {
|
|
62
|
+
indexRow = { pool_name: NAME };
|
|
63
|
+
statusRow = {
|
|
64
|
+
application_list: [],
|
|
65
|
+
crush_rule: 'replicated_rule',
|
|
66
|
+
id: 9,
|
|
67
|
+
min_size: 2,
|
|
68
|
+
name: NAME,
|
|
69
|
+
pg_autoscale_mode: 'on',
|
|
70
|
+
pg_num: 128,
|
|
71
|
+
size: 3,
|
|
72
|
+
};
|
|
73
|
+
return Response.json({ data: 'UPID:node-b:fake:createpool' });
|
|
74
|
+
}
|
|
75
|
+
if (wire === STATUS && request.method === 'GET') {
|
|
76
|
+
return statusRow === undefined ? absent() : Response.json({ data: statusRow });
|
|
77
|
+
}
|
|
78
|
+
if (wire === OBJECT && request.method === 'DELETE') {
|
|
79
|
+
indexRow = undefined;
|
|
80
|
+
statusRow = undefined;
|
|
81
|
+
return Response.json({ data: 'UPID:node-b:fake:destroypool' });
|
|
82
|
+
}
|
|
83
|
+
return Response.json({ data: null });
|
|
84
|
+
};
|
|
85
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
86
|
+
return {
|
|
87
|
+
layer: FetchHttpClient.layer.pipe(
|
|
88
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
89
|
+
),
|
|
90
|
+
listAsIfPresent: () => {
|
|
91
|
+
indexRow = { pool_name: NAME };
|
|
92
|
+
},
|
|
93
|
+
writes,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const declared = () =>
|
|
98
|
+
ProxmoxCephPool(NAME, {
|
|
99
|
+
crush_rule: 'replicated_rule',
|
|
100
|
+
min_size: 2,
|
|
101
|
+
name: NAME,
|
|
102
|
+
node: NODE,
|
|
103
|
+
pg_autoscale_mode: 'on',
|
|
104
|
+
size: 3,
|
|
105
|
+
target: FAKE_TARGET,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
109
|
+
engineOver(ProxmoxCephPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
110
|
+
|
|
111
|
+
describe('a genuinely new pool', () => {
|
|
112
|
+
test('creates once confirmAbsent finds the index empty, and settle confirms the match', async () => {
|
|
113
|
+
const fake = cluster();
|
|
114
|
+
await withoutBao(async () => {
|
|
115
|
+
const engine = engineFor(fake);
|
|
116
|
+
expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
|
|
117
|
+
});
|
|
118
|
+
expect(fake.writes).toEqual([`POST ${INDEX}`]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("the PG-merge guard: the index lists the pool but its status can't be read", () => {
|
|
123
|
+
test('refuses the create rather than risking a PG merge on a live pool', async () => {
|
|
124
|
+
const fake = cluster();
|
|
125
|
+
fake.listAsIfPresent(); // index says present; statusRow stays undefined -> every read 500s
|
|
126
|
+
await withoutBao(async () => {
|
|
127
|
+
const engine = engineFor(fake);
|
|
128
|
+
await expect(engine.deploy(declared())).rejects.toBeDefined();
|
|
129
|
+
});
|
|
130
|
+
// ⛔ THE ASSERTION THAT MATTERS: no POST ever reached the fake cluster.
|
|
131
|
+
expect(fake.writes).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('destroying a pool', () => {
|
|
136
|
+
test('RemovalPolicy.destroy() then undeclaring sends exactly one DELETE', async () => {
|
|
137
|
+
const fake = cluster();
|
|
138
|
+
await withoutBao(async () => {
|
|
139
|
+
const engine = engineFor(fake);
|
|
140
|
+
expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
|
|
141
|
+
await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
|
|
142
|
+
await engine.deploy(Effect.void);
|
|
143
|
+
});
|
|
144
|
+
expect(fake.writes).toEqual([`POST ${INDEX}`, `DELETE ${OBJECT}`]);
|
|
145
|
+
});
|
|
146
|
+
});
|
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(
|
|
@@ -97,20 +97,15 @@ export const readGroupOrFail = (props: GroupProps) =>
|
|
|
97
97
|
);
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* ⛔
|
|
101
|
-
* PVE
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
|
|
107
|
-
* credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
|
|
108
|
-
* above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
|
|
109
|
-
* compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
|
|
110
|
-
* propagates and fails the whole plan loudly — never silently misreports one row.
|
|
100
|
+
* ⛔ ONLY `GroupNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
|
|
101
|
+
* catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
|
|
102
|
+
* measured message, so permission errors, unrelated 500s and exhausted transports propagate
|
|
103
|
+
* instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
|
|
104
|
+
* ★ `readGroupOrFail` remains the strict path used for an already-confirmed state row. This
|
|
105
|
+
* change narrows cold reads/reconcile without changing that existing drift/diff contract.
|
|
111
106
|
*/
|
|
112
107
|
export const readGroup = (props: GroupProps) =>
|
|
113
|
-
readGroupOrFail(props).pipe(Effect.
|
|
108
|
+
readGroupOrFail(props).pipe(Effect.catchTag('GroupNotFound', () => Effect.succeed(undefined)));
|
|
114
109
|
|
|
115
110
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
116
111
|
export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* field, never the whole declared props object — the root cause: distilled's `buildRequest`
|
|
9
9
|
* treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
|
|
10
10
|
* a bodyless GET, which a stricter fetch client refuses outright.
|
|
11
|
-
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent"
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent".
|
|
12
|
+
* The later typed-absence follow-up extends that protection to cold `read`/`reconcile`:
|
|
13
|
+
* only the SDK's resource-specific missing-object tags may trigger a create.
|
|
14
14
|
*/
|
|
15
15
|
import { describe, expect, test } from 'bun:test';
|
|
16
16
|
import * as Layer from 'effect/Layer';
|
|
@@ -116,8 +116,8 @@ describe('a transient read failure fails the plan loudly, never a silent false u
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
// ★ A brand-new declaration's own create-from-a-500 path is already covered end to end by
|
|
119
|
-
// user.test.ts's `missingAs500` fixture
|
|
120
|
-
//
|
|
119
|
+
// user.test.ts's `missingAs500` fixture. The typed-absence follow-up keeps that path by
|
|
120
|
+
// catching `UserNotFound`, while unrelated 500s now propagate on cold reads too.
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
|
|
@@ -93,18 +93,17 @@ export const readStorageOrFail = (props: StorageProps) =>
|
|
|
93
93
|
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* ⛔
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* nothing compared (the ORIGINAL cries-wolf bug this family shipped with, per unreadable-read.ts's
|
|
103
|
-
* own header). `diff` calls `readStorageOrFail` directly, so a genuine failure there propagates
|
|
104
|
-
* and fails the whole plan loudly instead.
|
|
96
|
+
* ⛔ ONLY `StorageNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
|
|
97
|
+
* catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
|
|
98
|
+
* measured message, so permission errors, unrelated 500s and exhausted transports propagate
|
|
99
|
+
* instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
|
|
100
|
+
* ★ `readStorageOrFail` remains the strict path used for an already-confirmed state row. This
|
|
101
|
+
* change narrows cold reads/reconcile without changing that existing drift/diff contract.
|
|
105
102
|
*/
|
|
106
103
|
export const readStorage = (props: StorageProps) =>
|
|
107
|
-
readStorageOrFail(props).pipe(
|
|
104
|
+
readStorageOrFail(props).pipe(
|
|
105
|
+
Effect.catchTag('StorageNotFound', () => Effect.succeed(undefined)),
|
|
106
|
+
);
|
|
108
107
|
|
|
109
108
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
110
109
|
export const dropUnreadable = (live: StorageAttributes | Unreadable | undefined) =>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failed read is not evidence of absence. Exercise the real SDK's protocol and errors,
|
|
3
|
+
* including PVE's resource-specific HTTP 500s, before checking the engine's write boundary.
|
|
4
|
+
* Fixtures come from the vendor-source evidence in the SDK's generated error declarations.
|
|
5
|
+
* All HTTP is in-process, and `withoutBao` removes the shell's real credentials.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
12
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
13
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
14
|
+
import { readDaemon } from './ceph-daemon-wire.ts';
|
|
15
|
+
import { readFs } from './ceph-fs-distilled.ts';
|
|
16
|
+
import { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
|
|
17
|
+
import { readPoolStatus } from './ceph-pool-wire.ts';
|
|
18
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { readGroup } from './group-wire.ts';
|
|
20
|
+
import { readStorage } from './storage-wire.ts';
|
|
21
|
+
import { readUser } from './user-wire.ts';
|
|
22
|
+
import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
23
|
+
|
|
24
|
+
const target = FAKE_TARGET;
|
|
25
|
+
const ceph = { name: 'testfs', node: 'node-b', target };
|
|
26
|
+
const user = { userid: 'test@pve', target };
|
|
27
|
+
|
|
28
|
+
/** A mutable answer lets a formerly readable filesystem fail exactly when delete runs. */
|
|
29
|
+
const cluster = (initial: () => Response) => {
|
|
30
|
+
let answer = initial;
|
|
31
|
+
const writes: string[] = [];
|
|
32
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
33
|
+
const request =
|
|
34
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
35
|
+
if (new URL(request.url).pathname.includes('/creds/')) {
|
|
36
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
37
|
+
return Response.json({ data, lease_duration: 0 });
|
|
38
|
+
}
|
|
39
|
+
if (request.method !== 'GET') writes.push(request.method);
|
|
40
|
+
return answer();
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
answer: (next: () => Response) => {
|
|
44
|
+
answer = next;
|
|
45
|
+
},
|
|
46
|
+
layer: FetchHttpClient.layer.pipe(
|
|
47
|
+
Layer.provideMerge(
|
|
48
|
+
Layer.succeed(
|
|
49
|
+
FetchHttpClient.Fetch,
|
|
50
|
+
Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch,
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
writes,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface ReadCase {
|
|
59
|
+
name: string;
|
|
60
|
+
read: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
|
|
61
|
+
missing?: string;
|
|
62
|
+
}
|
|
63
|
+
const cases: ReadCase[] = [
|
|
64
|
+
{ name: 'User', read: () => readUser(user), missing: "no such user ('test@pve')\n" },
|
|
65
|
+
{
|
|
66
|
+
name: 'Group',
|
|
67
|
+
read: () => readGroup({ groupid: 'test', target }),
|
|
68
|
+
missing: "group 'test' does not exist\n",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'Storage',
|
|
72
|
+
read: () => readStorage({ storage: 'test', type: 'dir', target }),
|
|
73
|
+
missing: "storage 'test' does not exist\n",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'CephPool',
|
|
77
|
+
read: () => readPoolStatus(ceph),
|
|
78
|
+
missing: "error with 'osd pool get': mon_cmd failed - unrecognized pool 'testfs'\n",
|
|
79
|
+
},
|
|
80
|
+
{ name: 'CephFs', read: () => readFs(ceph) },
|
|
81
|
+
...(['mon', 'mgr', 'mds'] as const).map((kind) => ({
|
|
82
|
+
name: `CephDaemon/${kind}`,
|
|
83
|
+
read: () => readDaemon({ ...ceph, kind }),
|
|
84
|
+
})),
|
|
85
|
+
];
|
|
86
|
+
const errorResponse = (status: number, message: string) => () =>
|
|
87
|
+
Response.json({ data: null, message }, { status });
|
|
88
|
+
|
|
89
|
+
for (const entry of cases) {
|
|
90
|
+
describe(entry.name, () => {
|
|
91
|
+
test('only the measured absence signal returns undefined', async () => {
|
|
92
|
+
const fake = cluster(
|
|
93
|
+
entry.missing === undefined
|
|
94
|
+
? () => Response.json({ data: [] })
|
|
95
|
+
: errorResponse(500, entry.missing),
|
|
96
|
+
);
|
|
97
|
+
await withoutBao(async () => {
|
|
98
|
+
expect(
|
|
99
|
+
await Effect.runPromise(entry.read().pipe(Effect.provide(fake.layer))),
|
|
100
|
+
).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
expect(fake.writes).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
for (const [status, tag] of [
|
|
105
|
+
[401, 'Unauthorized'],
|
|
106
|
+
[403, 'Forbidden'],
|
|
107
|
+
[500, 'InternalServerError'],
|
|
108
|
+
] as const) {
|
|
109
|
+
test(`${status} unrelated failure propagates ${tag}`, async () => {
|
|
110
|
+
const fake = cluster(errorResponse(status, 'unrelated read failure'));
|
|
111
|
+
await withoutBao(async () => {
|
|
112
|
+
const error = await Effect.runPromise(
|
|
113
|
+
entry.read().pipe(Effect.flip, Effect.provide(fake.layer)),
|
|
114
|
+
);
|
|
115
|
+
expect(error).toMatchObject({ _tag: tag });
|
|
116
|
+
});
|
|
117
|
+
expect(fake.writes).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test('a cold User adoption fails before any create when its read fails', async () => {
|
|
124
|
+
const fake = cluster(errorResponse(500, 'unrelated read failure'));
|
|
125
|
+
await withoutBao(async () => {
|
|
126
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
127
|
+
await expect(engine.deploy(ProxmoxUser('test', user))).rejects.toBeDefined();
|
|
128
|
+
});
|
|
129
|
+
expect(fake.writes).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('CephFs delete fails on an unreadable index instead of falsely dropping state', async () => {
|
|
133
|
+
const fake = cluster(() =>
|
|
134
|
+
Response.json({
|
|
135
|
+
data: [{ name: ceph.name, data_pool: 'testfs_data', metadata_pool: 'testfs_metadata' }],
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
await withoutBao(async () => {
|
|
139
|
+
const engine = engineOver(ProxmoxCephFsProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
140
|
+
const declared = () => ProxmoxCephFs('fs', ceph).pipe(RemovalPolicy.destroy());
|
|
141
|
+
expect(await engine.deploy(declared())).toEqual({ fs: 'adopted' });
|
|
142
|
+
fake.answer(errorResponse(403, 'permission denied'));
|
|
143
|
+
await expect(engine.deploy(Effect.void)).rejects.toBeDefined();
|
|
144
|
+
});
|
|
145
|
+
expect(fake.writes).toEqual([]);
|
|
146
|
+
});
|