@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.
Files changed (116) hide show
  1. package/README.md +14 -0
  2. package/dist/argocd/cluster.d.ts +102 -0
  3. package/dist/argocd/cluster.d.ts.map +1 -0
  4. package/dist/argocd/credentials.d.ts +37 -0
  5. package/dist/argocd/credentials.d.ts.map +1 -0
  6. package/dist/argocd/fake-argocd.d.ts +42 -0
  7. package/dist/argocd/fake-argocd.d.ts.map +1 -0
  8. package/dist/argocd/git-credentials.d.ts +29 -0
  9. package/dist/argocd/git-credentials.d.ts.map +1 -0
  10. package/dist/argocd/index.d.ts +19 -0
  11. package/dist/argocd/index.d.ts.map +1 -0
  12. package/dist/argocd/index.js +306 -0
  13. package/dist/argocd/index.js.map +17 -0
  14. package/dist/argocd/providers.d.ts +13 -0
  15. package/dist/argocd/providers.d.ts.map +1 -0
  16. package/dist/argocd/repo-creds.d.ts +67 -0
  17. package/dist/argocd/repo-creds.d.ts.map +1 -0
  18. package/dist/argocd/repository.d.ts +87 -0
  19. package/dist/argocd/repository.d.ts.map +1 -0
  20. package/dist/argocd/resource.d.ts +90 -0
  21. package/dist/argocd/resource.d.ts.map +1 -0
  22. package/dist/cloudflare/index.js +4 -4
  23. package/dist/cloudflare/index.js.map +3 -3
  24. package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
  25. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  26. package/dist/litellm/index.js.map +2 -2
  27. package/dist/litellm/operations.d.ts.map +1 -1
  28. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  29. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  30. package/dist/proxmox/ceph-daemon-wire.d.ts +31 -0
  31. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  32. package/dist/proxmox/ceph-daemon.d.ts +40 -6
  33. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  34. package/dist/proxmox/ceph-fs-distilled.d.ts +33 -0
  35. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  36. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  37. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  38. package/dist/proxmox/ceph-fs.d.ts +30 -6
  39. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  40. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  41. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  43. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-osd.d.ts +6 -0
  45. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  46. package/dist/proxmox/ceph-pool-settle.d.ts +19 -12
  47. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-pool-wire.d.ts +37 -0
  49. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-pool.d.ts +10 -5
  51. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  52. package/dist/proxmox/group-wire.d.ts +8 -13
  53. package/dist/proxmox/group-wire.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +605 -483
  55. package/dist/proxmox/index.js.map +20 -17
  56. package/dist/proxmox/storage-wire.d.ts +15 -11
  57. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  58. package/dist/proxmox/unreadable-read.d.ts +7 -7
  59. package/dist/proxmox/user-wire.d.ts +8 -11
  60. package/dist/proxmox/user-wire.d.ts.map +1 -1
  61. package/dist/proxmox/user.d.ts +5 -5
  62. package/docs/argocd-kubernetes.md +100 -0
  63. package/docs/argocd.md +139 -0
  64. package/docs/credentials.md +9 -0
  65. package/docs/distilled-interim.md +15 -3
  66. package/docs/litellm.md +17 -0
  67. package/docs/peers.md +7 -0
  68. package/docs/provider-standard.md +24 -6
  69. package/docs/proxmox.md +20 -0
  70. package/docs/unifi.md +45 -0
  71. package/docs/upstream-conformance-bun.md +67 -0
  72. package/docs/upstream-conformance-conforms.md +55 -0
  73. package/docs/upstream-conformance-families.md +95 -0
  74. package/docs/upstream-conformance-grafana.md +106 -0
  75. package/docs/upstream-conformance.md +20 -298
  76. package/package.json +8 -3
  77. package/src/argocd/cluster.test.ts +120 -0
  78. package/src/argocd/cluster.ts +222 -0
  79. package/src/argocd/credentials.ts +38 -0
  80. package/src/argocd/fake-argocd.ts +60 -0
  81. package/src/argocd/git-credentials.ts +71 -0
  82. package/src/argocd/index.ts +40 -0
  83. package/src/argocd/providers.ts +17 -0
  84. package/src/argocd/repo-creds.test.ts +123 -0
  85. package/src/argocd/repo-creds.ts +115 -0
  86. package/src/argocd/repository.test.ts +167 -0
  87. package/src/argocd/repository.ts +165 -0
  88. package/src/argocd/resource.ts +150 -0
  89. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  90. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  91. package/src/litellm/operations.ts +8 -4
  92. package/src/proxmox/ceph-daemon-form.ts +7 -8
  93. package/src/proxmox/ceph-daemon-wire.ts +112 -0
  94. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  95. package/src/proxmox/ceph-daemon.ts +70 -53
  96. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  97. package/src/proxmox/ceph-fs-distilled.ts +100 -0
  98. package/src/proxmox/ceph-fs-wire.ts +17 -31
  99. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  100. package/src/proxmox/ceph-fs.ts +68 -73
  101. package/src/proxmox/ceph-osd-tree.ts +14 -5
  102. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  103. package/src/proxmox/ceph-osd-write.ts +33 -6
  104. package/src/proxmox/ceph-osd.ts +6 -0
  105. package/src/proxmox/ceph-pool-settle.ts +24 -17
  106. package/src/proxmox/ceph-pool-wire.ts +101 -0
  107. package/src/proxmox/ceph-pool-write.test.ts +146 -0
  108. package/src/proxmox/ceph-pool.ts +87 -98
  109. package/src/proxmox/group-wire.ts +7 -12
  110. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  111. package/src/proxmox/storage-wire.ts +9 -10
  112. package/src/proxmox/typed-absence.test.ts +146 -0
  113. package/src/proxmox/unreadable-read.ts +7 -7
  114. package/src/proxmox/user-wire.ts +7 -10
  115. package/src/proxmox/user.ts +5 -5
  116. 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
+ });
@@ -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. A `PveSpec`
9
- * carries ONE `path` for read, update and delete, so this family cannot be spelled in it. `path`
10
- * below is therefore the READ path and nothing else consumes it: `ops.reconcile` and
11
- * `ops.destroy` go unused and the two write handlers name their own path. acl.ts is the
12
- * precedent for hand-writing the five handlers over `pveOperations`.
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 { pve } from './client.ts';
55
- import { type PveRequirements, type PveSpec, pveOperations } from './resource.ts';
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
- const spec: PveSpec<CephPoolProps, CephPoolAttributes> = {
75
- /**
76
- * ⛔ `name` IS THE PRESENCE TEST, AND IT IS ALSO HOW THE INDEX TRAP FAILS SAFE. Pointed at
77
- * `.../pool/{name}` rather than `.../status`, the factory would hand this function an ARRAY:
78
- * every field would read absent and the plan would report an update no write can satisfy.
79
- * `getpool` always returns `name` for a real pool, so that shape answers "not there" instead
80
- * — and "not there" cannot reach a create unless `confirmAbsent` agrees.
81
- */
82
- attributes: (live, props) => {
83
- if (typeof live['name'] !== 'string') return undefined;
84
- return {
85
- applications: applications(live['application_list']),
86
- crush_rule: text(live['crush_rule']),
87
- id: int(live['id'], UNSET),
88
- min_size: int(live['min_size'], UNSET),
89
- name: props.name,
90
- node: props.node,
91
- nodelete: bool(live['nodelete']),
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 }) => ops.read(olds),
150
- diff: ({ news, output }) => ops.diff(news, output),
111
+ read: ({ olds }) => readPoolStatus(olds),
151
112
  /**
152
- * ⚠️ NOT `ops.reconcile`: it POSTs the moment a read comes back empty, and reads back once,
153
- * immediately. Both are wrong for a family whose writes are forked workers and whose
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* ops.read(news);
137
+ const live = yield* readPoolStatus(news);
158
138
  let upid: string | undefined;
159
139
  if (live === undefined) {
160
- // ⛔ THE GUARD `ops.reconcile` WOULD HAVE RUN, RESTORED. This handler replaces it
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* ops.guardCreate(news, true);
164
- yield* confirmAbsent(news, spec.collection(news), object(news));
165
- upid = yield* pve<string>(
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
- 'POST',
169
- collection(news),
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(spec.matches, live, news, updateBody(news));
159
+ const form = formToSend(matches, live, news, updateBody(news));
181
160
  if (form !== undefined) {
182
- yield* ops.guardUpdate(news);
183
- upid = yield* pve<string>(news.target, 'provision', 'PUT', object(news), form);
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
- ops.read,
189
- (row) => row !== undefined && spec.matches(row, news),
172
+ readPoolStatus,
173
+ (row) => row !== undefined && matches(row, news),
190
174
  );
191
- if (after === undefined || !spec.matches(after, news)) {
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* pve<string>(olds.target, 'provision', 'DELETE', object(olds));
216
- const left = yield* settle(olds, ops.read, (row) => row === undefined);
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
- * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT.
101
- * PVE's OWN missing-group answer is a thrown 500 (the ⛔ above), so `reconcile`'s create branch
102
- * NEEDS "absent" from a failure to ever run at all — without this fold, a brand-new group could
103
- * never be created, because its very-first read would always fail loudly. Safe here because a
104
- * wrongful fold costs at most a redundant `createAccessGroup` POST, which PVE refuses loudly
105
- * ("group already exists") rather than silently corrupting anything.
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.orElseSucceed(() => undefined));
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" — only
12
- * `read`/`reconcile` may, where a wrongful fold costs at most a redundant, loudly-refused
13
- * create, never a silent wrong write.
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 — `readUser`/`readGroup` (used by `reconcile`) kept
120
- // their fold on purpose, exactly so that path stays unaffected by this fix.
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
- * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT —
97
- * user.ts's `readUser` has the full reasoning. Safe here because a wrongful fold costs at most a
98
- * redundant `createStorage` POST, which PVE refuses loudly ("storage ID already exists") rather
99
- * than silently corrupting anything.
100
- * ⛔ `storage.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
101
- * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
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(Effect.orElseSucceed(() => undefined));
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
+ });