@homeflare/alchemy 0.37.1 → 0.37.2

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 (43) hide show
  1. package/dist/proxmox/ceph-fs-distilled.d.ts +17 -10
  2. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
  3. package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
  4. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  5. package/dist/proxmox/ceph-fs.d.ts +3 -3
  6. package/dist/proxmox/group-wire.d.ts +2 -2
  7. package/dist/proxmox/group-wire.d.ts.map +1 -1
  8. package/dist/proxmox/index.js +295 -313
  9. package/dist/proxmox/index.js.map +5 -5
  10. package/dist/proxmox/node-network-wire.d.ts +1 -1
  11. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  12. package/dist/proxmox/storage-wire.d.ts +2 -2
  13. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  14. package/dist/proxmox/user-wire.d.ts +2 -2
  15. package/dist/proxmox/user-wire.d.ts.map +1 -1
  16. package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
  17. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
  18. package/docs/changelog/README.md +22 -0
  19. package/docs/changelog/archive-01.md +176 -0
  20. package/docs/changelog/archive-02.md +180 -0
  21. package/docs/changelog/archive-03.md +179 -0
  22. package/docs/changelog/archive-04.md +178 -0
  23. package/docs/changelog/archive-05.md +177 -0
  24. package/docs/changelog/archive-06.md +180 -0
  25. package/docs/changelog/archive-07.md +180 -0
  26. package/docs/changelog/archive-08.md +178 -0
  27. package/docs/changelog/archive-09.md +181 -0
  28. package/docs/changelog/archive-10.md +182 -0
  29. package/docs/changelog/archive-11.md +182 -0
  30. package/docs/changelog/archive-12.md +183 -0
  31. package/docs/changelog/archive-13.md +180 -0
  32. package/docs/changelog/archive-14.md +178 -0
  33. package/docs/changelog/archive-15.md +181 -0
  34. package/docs/changelog/archive-16.md +182 -0
  35. package/docs/changelog/archive-17.md +183 -0
  36. package/docs/changelog/archive-18.md +55 -0
  37. package/docs/proxmox.md +9 -3
  38. package/package.json +3 -3
  39. package/src/proxmox/ceph-fs-delete.test.ts +5 -8
  40. package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
  41. package/src/proxmox/ceph-fs-distilled.ts +41 -13
  42. package/src/proxmox/ceph-fs-wire.ts +5 -93
  43. package/src/proxmox/ceph-fs.ts +7 -7
@@ -0,0 +1,55 @@
1
+ # Alchemy changelog archive 18
2
+
3
+ [Current changelog](../../CHANGELOG.md) · [Archive index](./README.md)
4
+
5
+ 1. **`@effect/platform-node` was a devDependency**, so a consumer got
6
+ `Cannot find module '@effect/platform-node/NodeServices'`. Alchemy's module graph
7
+ reaches `Cloudflare/Workers/WorkerBridge` even when you only import `/proxmox`, so it
8
+ is a required peer, not an optional one.
9
+ 2. **The `effect` peer was ranged** `>=4.0.0-rc.112`, which resolves to rc.115 —
10
+ `TypeError: Config.string is not a function`. Effect's rc line is not
11
+ semver-compatible with itself, so a range is a promise this package cannot keep. Peers
12
+ are pinned exactly now.
13
+ 3. **`@effect/platform-node-shared` still resolves up** to rc.115 against effect rc.112
14
+ (`Cannot find module 'effect/ByteSize'`), because `platform-bun@rc.112` asks for
15
+ `^4.0.0-rc.112`. Only a consumer-side `overrides` block holds the set together, and the
16
+ README now says so with the exact block to paste.
17
+
18
+ ⛔ The smoke script was `echo '…exercised by the consuming stack'` — a check that cannot
19
+ fail, which is how all three shipped. It now packs the tarball, installs it the way the
20
+ README says, and **imports every subpath**, because each of these threw at import rather
21
+ than at install.
22
+
23
+ ## 0.1.0
24
+
25
+ ### Minor Changes
26
+
27
+ - [#25](https://github.com/taslabs-net/homeflare-kit/pull/25) [`309c644`](https://github.com/taslabs-net/homeflare-kit/commit/309c644d83c7149571f4caada135d1b8d141a484) Thanks [@taslabs-net](https://github.com/taslabs-net)! - New package: custom Alchemy providers for five systems the vendor has none for.
28
+
29
+ **`R2BucketLock`** — an R2 bucket's lock rules, declared rather than applied by hand. A
30
+ lock rule is a retention floor: while a rule covers an object, no API call, no lifecycle
31
+ rule and no credential can delete it.
32
+
33
+ ★ Alchemy 2.0.0-beta.77 has no lock property anywhere in its R2 namespace, so the
34
+ alternative was a runbook step a human runs once — and a plan can never show a missing
35
+ runbook step. Covering the gap with a resource makes the drift visible in `plan`.
36
+
37
+ ⛔ Deletion is refused by design: removing a lock removes a retention floor, which is the
38
+ one operation this resource exists to make hard.
39
+
40
+ ⚠️ `alchemy`, `cloudflare` and `effect` are **peers**, not dependencies — Alchemy's
41
+ resource registry and Effect's context both break if two copies load in one process.
42
+
43
+ **Also in this release** — the same treatment for four more systems, 140 files in all:
44
+
45
+ - **`/proxmox`** (61 source files) — ACLs, API tokens, backup jobs, Ceph, SDN, storage.
46
+ - **`/openbao`** (33) — mounts, policies, PKI/SSH/auth roles, Cloudflare role expansion.
47
+ - **`/forgejo`** (12) — branch protection, org secrets, labels, webhooks.
48
+ - **`/talos`** (8) — cluster bootstrap, machine config, health.
49
+
50
+ ⛔ **Import a subpath, never the root.** Each system carries its own client, so a root
51
+ barrel would pull Proxmox into a stack that only wanted Forgejo.
52
+
53
+ ★ **The barrels are deliberately smaller than the directories** — 70 exported symbols out
54
+ of 606 defined, chosen from what a real stack actually consumes. An `export *` would
55
+ publish every helper as API and make the next refactor a breaking change.
package/docs/proxmox.md CHANGED
@@ -37,7 +37,13 @@ CephFS index cannot produce a successful delete. Existing adoption fixtures prov
37
37
  matching objects remain no-ops. Credential-denied warnings are still not live no-op
38
38
  evidence; use the consumer's authorized read lane and `hf-adopt-verify --all`.
39
39
 
40
- This is a scoped follow-up to the transport migration. CephFS delete still uses the old
41
- client despite the SDK's now-fixed DELETE query encoding; bounded task polling and
42
- other pre-existing provider refusals retain their own policies. No live writes are part
40
+ This is a scoped follow-up to the transport migration. CephFS delete now uses SDK 0.3.0's fixed
41
+ DELETE query binding and folds only CephFsNotFound. Its existing destructive flags,
42
+ bounded task polling and final index read remain in place. Other provider families
43
+ retain their own policies. No live writes are part
43
44
  of this validation. Released-consumer plans remain a separate gate from these fixtures.
45
+
46
+ `ceph-fs-destroy-sdk.test.ts` verifies that remove-pools/remove-storages are query
47
+ parameters with an empty DELETE body, omitted flags retain the safe defaults, an
48
+ already-missing filesystem succeeds, and unrelated 401/403/500 responses fail.
49
+ The engine destroy fixture still proves the complete read/delete/poll/read-back flow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.37.1",
3
+ "version": "0.37.2",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -124,8 +124,8 @@
124
124
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
125
125
  "@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.2.0",
126
126
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
127
- "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.0",
128
- "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.0",
127
+ "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.1",
128
+ "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
129
129
  "@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.2.0"
130
130
  },
131
131
  "peerDependencies": {
@@ -1,11 +1,8 @@
1
1
  /**
2
- * `Proxmox.CephFs`'s destroy path — the one place in this migration that composes a distilled
3
- * call (`readFs`, before and after) with a hand-client one (`destroyFs`, unmigrated — ceph-fs-
4
- * wire.ts's header has the measured protocol reason) inside a single handler. Flagged by
5
- * adversarial review as untested anywhere in the repo before this file: `readFs` alone is proven
6
- * by ceph-fs-write.test.ts and ceph-adopt.test.ts, `destroyFs` alone is unchanged from main, but
7
- * the two composed together — on a destructive, hard-to-reverse `RemovalPolicy.destroy()` — had
8
- * no coverage of the seam itself.
2
+ * The CephFS destroy lifecycle through the real SDK protocol and Alchemy engine.
3
+ * The original transport walk left DELETE on the hand client; SDK 0.3.0's query
4
+ * binding now permits the whole sequence to move. These tests keep the destructive
5
+ * read/delete/poll/read-back boundary covered without touching a real filesystem.
9
6
  */
10
7
  import { describe, expect, test } from 'bun:test';
11
8
  import * as RemovalPolicy from 'alchemy/RemovalPolicy';
@@ -27,7 +24,7 @@ const declared = () => ProxmoxCephFs(NAME, { name: NAME, node: NODE, target: FAK
27
24
  describe('destroying a CephFS', () => {
28
25
  test(
29
26
  'RemovalPolicy.destroy() then undeclaring: readFs (distilled) finds it, destroyFs ' +
30
- '(client.ts) removes it, and the distilled read-back confirms it is gone',
27
+ '(distilled) removes it, and the distilled read-back confirms it is gone',
31
28
  async () => {
32
29
  let destroyed = false;
33
30
  const fake = fakePve((call: PveCall) => {
@@ -0,0 +1,98 @@
1
+ /** The real SDK must send destructive flags in the query and fold only its missing-FS tag. */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Layer from 'effect/Layer';
5
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
6
+ import { destroyFs } from './ceph-fs-distilled.ts';
7
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
8
+
9
+ const props = { name: 'oldfs', node: 'node-b', target: FAKE_TARGET };
10
+ const UPID = 'UPID:node-b:fake:destroyfs';
11
+ const fixture = (answer: () => Response) => {
12
+ const calls: { method: string; path: string; body: string }[] = [];
13
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
14
+ const request =
15
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
16
+ const url = new URL(request.url);
17
+ if (url.pathname.includes('/creds/')) {
18
+ return Response.json({
19
+ data: { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' },
20
+ lease_duration: 0,
21
+ });
22
+ }
23
+ calls.push({
24
+ method: request.method,
25
+ path: url.pathname + url.search,
26
+ body: await request.text(),
27
+ });
28
+ return request.method === 'DELETE'
29
+ ? answer()
30
+ : Response.json({ data: { status: 'stopped', exitstatus: 'OK' } });
31
+ };
32
+ const layer = FetchHttpClient.layer.pipe(
33
+ Layer.provideMerge(
34
+ Layer.succeed(
35
+ FetchHttpClient.Fetch,
36
+ Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch,
37
+ ),
38
+ ),
39
+ );
40
+ return { calls, layer };
41
+ };
42
+
43
+ describe('CephFS distilled delete', () => {
44
+ test('destructive flags reach the query, never the DELETE body, and the worker is polled', async () => {
45
+ const fake = fixture(() => Response.json({ data: UPID }));
46
+ await withoutBao(() =>
47
+ Effect.runPromise(
48
+ destroyFs({ ...props, 'remove-pools': true, 'remove-storages': true }).pipe(
49
+ Effect.provide(fake.layer),
50
+ ),
51
+ ),
52
+ );
53
+ expect(fake.calls[0]).toEqual({
54
+ method: 'DELETE',
55
+ path: '/api2/json/nodes/node-b/ceph/fs/oldfs?remove-pools=1&remove-storages=1',
56
+ body: '',
57
+ });
58
+ expect(fake.calls[1]?.path).toBe(
59
+ `/api2/json/nodes/node-b/tasks/${encodeURIComponent(UPID)}/status`,
60
+ );
61
+ });
62
+
63
+ test('omitted or false flags preserve the safe defaults', async () => {
64
+ const fake = fixture(() => Response.json({ data: UPID }));
65
+ await withoutBao(() =>
66
+ Effect.runPromise(
67
+ destroyFs({ ...props, 'remove-pools': false }).pipe(Effect.provide(fake.layer)),
68
+ ),
69
+ );
70
+ expect(fake.calls[0]).toEqual({
71
+ method: 'DELETE',
72
+ path: '/api2/json/nodes/node-b/ceph/fs/oldfs',
73
+ body: '',
74
+ });
75
+ });
76
+
77
+ test('a race that already removed the filesystem is idempotent without a task poll', async () => {
78
+ const fake = fixture(() =>
79
+ Response.json({ data: null, message: "no such cephfs 'oldfs'\n" }, { status: 500 }),
80
+ );
81
+ await withoutBao(() => Effect.runPromise(destroyFs(props).pipe(Effect.provide(fake.layer))));
82
+ expect(fake.calls).toHaveLength(1);
83
+ });
84
+
85
+ for (const status of [401, 403, 500]) {
86
+ test(`an unrelated HTTP ${String(status)} fails instead of claiming deletion`, async () => {
87
+ const fake = fixture(() =>
88
+ Response.json({ data: null, message: 'refused or unavailable' }, { status }),
89
+ );
90
+ await withoutBao(async () => {
91
+ await expect(
92
+ Effect.runPromise(destroyFs(props).pipe(Effect.provide(fake.layer))),
93
+ ).rejects.toBeDefined();
94
+ });
95
+ expect(fake.calls).toHaveLength(1);
96
+ });
97
+ }
98
+ });
@@ -1,20 +1,16 @@
1
1
  /**
2
- * `Proxmox.CephFs`'s distilled half: the index read and the create — see ceph-fs-wire.ts's own
3
- * header for why `destroyFs` stays there, on `client.ts`, unmigrated.
2
+ * `Proxmox.CephFs`'s distilled transport: index, create, delete and task status.
3
+ * SDK 0.3.0 fixes DELETE query flags and supplies the measured CephFsNotFound tag.
4
4
  *
5
5
  * ⛔ `nodes.updateNodeCephFs` IS distilled's GENERATOR MISNAMING A CREATE, NOT A SECOND ENDPOINT.
6
6
  * Checked directly against its schema (`T.Http({ method: "POST", uri:
7
7
  * "/nodes/{node}/ceph/fs/{name}" })`) rather than trusted from the function name — it is the
8
8
  * SAME single write PVE registers, `createfs`. Harmless once named here for what it is.
9
9
  *
10
- * ★ THE SETTLE POLL IS A SEPARATE FUNCTION FROM ceph-fs-wire.ts's, NOT A SHARED ONE, because it
11
- * calls a different task-status operation on a different transport — `nodes.getNodeTaskStatus`
12
- * here, `pve()` there — but the POLICY is identical on purpose: same cadence
13
- * (`POLL_SECONDS`/`POLL_ATTEMPTS`, imported, not retyped), same "an unreadable status is not
14
- * settled yet, not a failure" tolerance. That tolerance is why this calls the RAW distilled
15
- * operation directly. The former SDK `Task.awaitTask` propagated poll failures; SDK PR #265
16
- * removed that helper because polling policy belongs in the provider. This bounded wait
17
- * still tolerates a missing `Sys.Audit` grant or a node mid-restart until its deadline.
10
+ * ★ CREATE AND DELETE SHARE THE SAME BOUNDED POLL POLICY. The raw getNodeTaskStatus
11
+ * operation preserves this family's tolerance of a missing Sys.Audit grant or a node
12
+ * mid-restart until the deadline. The former SDK Task.awaitTask propagated poll failures;
13
+ * SDK PR #265 removed it because polling policy belongs in the provider.
18
14
  */
19
15
  import * as nodes from '@distilled.cloud/proxmox/nodes';
20
16
  import * as Effect from 'effect/Effect';
@@ -43,8 +39,11 @@ const toDistilledCreate = (props: CephFsProps): nodes.UpdateNodeCephFsRequest =>
43
39
  node: props.node,
44
40
  }) as unknown as nodes.UpdateNodeCephFsRequest;
45
41
 
46
- /** Wait for the create's forked task — the distilled twin of ceph-fs-wire.ts's `settle`. */
47
- const settleCreate = (props: CephFsProps, upid: string) =>
42
+ /**
43
+ * Wait for a forked task. `exitstatus` is absent while running and exactly `OK` on success.
44
+ * ⚠️ T.Label encodes the colon-filled UPID as one path segment, as the hand client did.
45
+ */
46
+ const settleTask = (props: CephFsProps, upid: string) =>
48
47
  Effect.gen(function* () {
49
48
  for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
50
49
  const status = yield* runPve(
@@ -96,5 +95,34 @@ export const createFs = (props: CephFsProps) =>
96
95
  ),
97
96
  );
98
97
  }
99
- yield* settleCreate(props, upid);
98
+ yield* settleTask(props, upid);
99
+ });
100
+
101
+ /**
102
+ * DELETE the filesystem, then wait for the worker.
103
+ *
104
+ * ⛔ WHAT THIS TAKES AWAY DEPENDS ENTIRELY ON `remove-pools`, AND THE DEFAULT IS THE SAFE ONE. With
105
+ * it unset PVE removes the filesystem from the MDS map and LEAVES the two pools on disk: every
106
+ * mount breaks, but the bytes are still there for an operator to recover. With it set the pools
107
+ * go too and nothing is recoverable. Leaving it unset also means a later create of the SAME name
108
+ * fails with "ceph pools '<name>_data' and/or '<name>_metadata' already exist" -- that refusal is
109
+ * a BRAKE, not a bug, and it is the last thing standing between a mis-typed rename and the data.
110
+ */
111
+ export const destroyFs = (props: CephFsProps) =>
112
+ Effect.gen(function* () {
113
+ const upid = yield* runPve(
114
+ props.target,
115
+ 'provision',
116
+ true,
117
+ nodes.deleteNodeCephFs({
118
+ node: props.node,
119
+ name: props.name,
120
+ // ⛔ Query fields, never a DELETE body. Omitted/false retains PVE's safe default.
121
+ ...(props['remove-pools'] === true ? { remove_pools: '1' } : {}),
122
+ ...(props['remove-storages'] === true ? { remove_storages: '1' } : {}),
123
+ }),
124
+ ).pipe(Effect.catchTag('CephFsNotFound', () => Effect.succeed('')));
125
+ // ⚠️ Missing already is idempotent; every other SDK error propagates. The caller's
126
+ // successful index read must still confirm absence after the DELETE or task.
127
+ if (upid !== '') yield* settleTask(props, upid);
100
128
  });
@@ -49,21 +49,13 @@
49
49
  * Grant it on the `read` role, or every create burns the full cap below and reports the wrong
50
50
  * cause.
51
51
  *
52
- * ★ AS OF 2026-09-24 (decision 43's walk-down, 2c) THIS FILE IS `client.ts`'s HALF ONLY. The read
53
- * (`readRow`) moved to `ceph-fs-distilled.ts`, and so did the create — `nodes.updateNodeCephFs`
54
- * (distilled's own misnamed POST, confirmed against its `T.Http` annotation) plus a SEPARATE
55
- * distilled `settle`, using the raw `getNodeTaskStatus` operation in its own tolerant poll loop
56
- * rather than `Task.awaitTask`, which does not tolerate a poll failure the way this family needs
57
- * (network-apply-read.ts's own header has the fuller reasoning). `destroyFs` stays HERE,
58
- * unmigrated: distilled types `remove_pools`/`remove_storages` as `T.Body` on a DELETE, and the
59
- * ⛔ two paragraphs up is the MEASURED reason PVE would silently ignore both flags if sent that
60
- * way — a real protocol gap, not a judgement call. `settle` below is still shared where it can
61
- * be: `createForm`/`objectPath`/`destroyPath` are pure and reused by the distilled file too.
52
+ * ★ THE ORIGINAL DISTILLED WALK MOVED READ AND CREATE FIRST, leaving DELETE here because
53
+ * SDK flags were annotated as body fields. Distilled Proxmox 0.3.0 fixes their query binding
54
+ * and adds the measured CephFsNotFound tag. All calls now live in ceph-fs-distilled.ts;
55
+ * this file keeps the pure forms, attributes and read-back failure messages. The protocol
56
+ * history above remains load-bearing: moving a DELETE flag back into a body loses it silently.
62
57
  */
63
- import * as Effect from 'effect/Effect';
64
58
  import type { CephFsAttributes, CephFsProps } from './ceph-fs.ts';
65
- import { pve } from './client.ts';
66
- import type { PveTarget } from './credentials.ts';
67
59
  import { csv, flag, int, text } from './values.ts';
68
60
 
69
61
  /**
@@ -100,9 +92,6 @@ export const readRow = (
100
92
  };
101
93
  };
102
94
 
103
- /** ⚠️ `exitstatus` is ABSENT while a task runs, and is the exact string `OK` only on success. */
104
- type TaskStatus = { readonly status?: string; readonly exitstatus?: string };
105
-
106
95
  /**
107
96
  * ⚠️ SHORT POLLS, LOW CAP. Ninety seconds covers two pool creates plus the ten seconds `createfs`
108
97
  * itself spends waiting for an MDS to go active, and is short enough that a stuck task fails the
@@ -131,83 +120,6 @@ export const createForm = (props: CephFsProps): Record<string, string> => ({
131
120
  ...field('pg_num', props.pg_num === undefined ? undefined : String(props.pg_num)),
132
121
  });
133
122
 
134
- /** The delete target with its flags in the query string — see the ⛔ in the header. */
135
- const destroyPath = (props: CephFsProps) => {
136
- const query = [
137
- props['remove-pools'] === true ? 'remove-pools=1' : undefined,
138
- props['remove-storages'] === true ? 'remove-storages=1' : undefined,
139
- ].filter((part) => part !== undefined);
140
- return query.length === 0 ? objectPath(props) : `${objectPath(props)}?${query.join('&')}`;
141
- };
142
-
143
- /**
144
- * Wait for a forked PVE task, and fail loudly rather than quietly.
145
- *
146
- * ⚠️ THE UPID IS PERCENT-ENCODED BECAUSE IT IS FULL OF COLONS — `UPID:node-b:00396D3A:…:root@pam:`
147
- * (MEASURED shape, from `GET /nodes/node-b/tasks`) is ONE path segment, not seven. Encoding is what
148
- * guarantees it arrives as one; PVE's router decodes each segment before matching. REASONED from
149
- * the URI handling rather than measured — no task of this provider's has been polled yet.
150
- */
151
- const settle = (target: PveTarget, node: string, upid: string, what: string) =>
152
- Effect.gen(function* () {
153
- for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
154
- const status = yield* pve<TaskStatus>(
155
- target,
156
- 'read',
157
- 'GET',
158
- `nodes/${node}/tasks/${encodeURIComponent(upid)}/status`,
159
- ).pipe(
160
- // ⚠️ AN UNREADABLE STATUS IS "NOT SETTLED YET", NOT A FAILURE. It is a missing Sys.Audit
161
- // (see the header) or a node mid-restart; either way the task is still the cluster's
162
- // business, and the cap is what ends the wait rather than one unlucky GET.
163
- Effect.orElseSucceed(() => undefined),
164
- );
165
- if (status?.status === 'stopped') {
166
- const exit = text(status.exitstatus);
167
- if (exit === 'OK') return;
168
- return yield* Effect.die(
169
- new Error(
170
- `${what}: PVE task ${upid} finished with "${exit === '' ? 'no exit status' : exit}". ` +
171
- 'The write returned HTTP 200 because it only forked the worker -- the real error is ' +
172
- `in \`pvesh get /nodes/${node}/tasks/${upid}/log\`.`,
173
- ),
174
- );
175
- }
176
- // ⚠️ A BARE NUMBER IS MILLISECONDS to Effect's Duration, not seconds. The cap in the
177
- // message below is derived from the same constant so the two cannot drift apart.
178
- yield* Effect.sleep(POLL_SECONDS * 1000);
179
- }
180
- return yield* Effect.die(
181
- new Error(
182
- `${what}: PVE task ${upid} was still running after ` +
183
- `${String(POLL_ATTEMPTS * POLL_SECONDS)}s, or its ` +
184
- 'status could not be read. Check the task log, and check that the `read` role holds ' +
185
- `Sys.Audit on /nodes/${node} -- each call mints a new token, so the poller is never the ` +
186
- 'task owner and is answered 403 rather than told to wait.',
187
- ),
188
- );
189
- });
190
-
191
- /**
192
- * DELETE the filesystem, then wait for the worker.
193
- *
194
- * ⛔ WHAT THIS TAKES AWAY DEPENDS ENTIRELY ON `remove-pools`, AND THE DEFAULT IS THE SAFE ONE. With
195
- * it unset PVE removes the filesystem from the MDS map and LEAVES the two pools on disk: every
196
- * mount breaks, but the bytes are still there for an operator to recover. With it set the pools
197
- * go too and nothing is recoverable. Leaving it unset also means a later create of the SAME name
198
- * fails with "ceph pools '<name>_data' and/or '<name>_metadata' already exist" -- that refusal is
199
- * a BRAKE, not a bug, and it is the last thing standing between a mis-typed rename and the data.
200
- */
201
- export const destroyFs = (props: CephFsProps) =>
202
- Effect.gen(function* () {
203
- const upid = text(yield* pve<string>(props.target, 'provision', 'DELETE', destroyPath(props)));
204
- // ⚠️ A DESTROY THAT ANSWERS WITHOUT A UPID IS NOT REFUSED HERE. `destroyfs` dies synchronously
205
- // with "no such cephfs" when the filesystem is already gone, which surfaces as a failed
206
- // DELETE; an empty answer that is not an error is best treated as already-absent, and the
207
- // caller's read-back is what proves it either way.
208
- if (upid !== '') yield* settle(props.target, props.node, upid, destroyPath(props));
209
- });
210
-
211
123
  /**
212
124
  * The two read-back failures, as messages rather than as guesses.
213
125
  *
@@ -28,9 +28,9 @@
28
28
  *
29
29
  * ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
30
30
  * `nodes.listNodeCephFs`/`updateNodeCephFs` for the READ and the CREATE (2026-09-24, decision
31
- * 43's walk-down, 2c — ceph-fs-distilled.ts). `destroyFs` STAYS on `client.ts`, unmigrated:
32
- * the SDK originally put `remove-pools`/`remove-storages` in a DELETE body, which PVE ignores.
33
- * SDK PR #265 fixes that protocol gap; migrating `destroyFs` remains a separate change.
31
+ * 43's walk-down, 2c — ceph-fs-distilled.ts). DELETE originally stayed on `client.ts`:
32
+ * the SDK put its flags in a body that PVE discards (ceph-fs-wire.ts's history).
33
+ * SDK 0.3.0 fixes the query binding; delete now uses it and catches only CephFsNotFound.
34
34
  * The typed-read follow-up removes the catch-all fold from `readFs`: a failed index must
35
35
  * fail read/diff/reconcile/delete, not report a missing filesystem or a successful destroy.
36
36
  *
@@ -67,8 +67,8 @@ import { Resource } from 'alchemy';
67
67
  import { isResolved } from 'alchemy/Diff';
68
68
  import * as Provider from 'alchemy/Provider';
69
69
  import * as Effect from 'effect/Effect';
70
- import { createFs, readFs } from './ceph-fs-distilled.ts';
71
- import { CEPH_FS_CREATE, createForm, destroyFs, notCreated, notDestroyed } from './ceph-fs-wire.ts';
70
+ import { createFs, destroyFs, readFs } from './ceph-fs-distilled.ts';
71
+ import { CEPH_FS_CREATE, createForm, notCreated, notDestroyed } from './ceph-fs-wire.ts';
72
72
  import { guardWrite } from './distilled-guard.ts';
73
73
  import { type PveRequirements, type WithTarget } from './resource.ts';
74
74
 
@@ -222,8 +222,8 @@ export const ProxmoxCephFsProvider = () =>
222
222
  * ⛔ THE READ-BACK IS WHAT TURNS "the task said OK" INTO "it is actually gone": `destroyfs`
223
223
  * refuses while a non-disabled `cephfs` storage still references the filesystem, and that
224
224
  * refusal must fail the destroy rather than letting Alchemy drop the state entry for a
225
- * live object. `destroyFs` itself stays on `client.ts` — ceph-fs-wire.ts's own header has
226
- * the measured, protocol-level reason.
225
+ * live object. `destroyFs` now uses the SDK query binding — ceph-fs-wire.ts preserves
226
+ * the measured DELETE query-binding history.
227
227
  */
228
228
  delete: Effect.fn(function* ({ olds }) {
229
229
  /**