@homeflare/alchemy 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) 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 +33 -0
  31. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  32. package/dist/proxmox/ceph-daemon.d.ts +35 -0
  33. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  34. package/dist/proxmox/ceph-fs-distilled.d.ts +14 -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 +31 -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 +10 -4
  47. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-pool-wire.d.ts +46 -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 +1 -1
  53. package/dist/proxmox/group-wire.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +611 -477
  55. package/dist/proxmox/index.js.map +15 -12
  56. package/dist/proxmox/storage-wire.d.ts +8 -1
  57. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  58. package/dist/proxmox/user-wire.d.ts +1 -1
  59. package/dist/proxmox/user-wire.d.ts.map +1 -1
  60. package/docs/argocd-kubernetes.md +100 -0
  61. package/docs/argocd.md +139 -0
  62. package/docs/credentials.md +9 -0
  63. package/docs/litellm.md +17 -0
  64. package/docs/peers.md +7 -0
  65. package/docs/unifi.md +45 -0
  66. package/package.json +8 -3
  67. package/src/argocd/cluster.test.ts +120 -0
  68. package/src/argocd/cluster.ts +222 -0
  69. package/src/argocd/credentials.ts +38 -0
  70. package/src/argocd/fake-argocd.ts +60 -0
  71. package/src/argocd/git-credentials.ts +71 -0
  72. package/src/argocd/index.ts +40 -0
  73. package/src/argocd/providers.ts +17 -0
  74. package/src/argocd/repo-creds.test.ts +123 -0
  75. package/src/argocd/repo-creds.ts +115 -0
  76. package/src/argocd/repository.test.ts +167 -0
  77. package/src/argocd/repository.ts +165 -0
  78. package/src/argocd/resource.ts +150 -0
  79. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  80. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  81. package/src/litellm/operations.ts +8 -4
  82. package/src/proxmox/ceph-daemon-form.ts +7 -8
  83. package/src/proxmox/ceph-daemon-wire.ts +115 -0
  84. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  85. package/src/proxmox/ceph-daemon.ts +65 -47
  86. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  87. package/src/proxmox/ceph-fs-distilled.ts +102 -0
  88. package/src/proxmox/ceph-fs-wire.ts +17 -31
  89. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  90. package/src/proxmox/ceph-fs.ts +66 -69
  91. package/src/proxmox/ceph-osd-tree.ts +14 -5
  92. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  93. package/src/proxmox/ceph-osd-write.ts +33 -6
  94. package/src/proxmox/ceph-osd.ts +6 -0
  95. package/src/proxmox/ceph-pool-settle.ts +13 -7
  96. package/src/proxmox/ceph-pool-wire.ts +110 -0
  97. package/src/proxmox/ceph-pool-write.test.ts +145 -0
  98. package/src/proxmox/ceph-pool.ts +87 -98
  99. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `Proxmox.CephPool`'s write paths, through Alchemy's own Plan and Apply over a fake cluster —
3
+ * the paths ceph-pool-adopt.test.ts does not reach: a genuine create, the PG-merge guard that
4
+ * stops a create over a pool the read could not confirm, and a real destroy.
5
+ *
6
+ * ★ THE ABSENCE SIGNAL IS THE MEASURED ONE. `GET .../pool/{name}/status` on a name with no pool
7
+ * answers HTTP 500 (live TB4 `n2`, 2026-09-24, read-role, read-only probe:
8
+ * `{"data":null,"message":"error with 'osd pool get': mon_cmd failed - unrecognized pool
9
+ * '<name>'\n"}`) — a generic failure, not a parsed field like `Proxmox.NodeNetwork`'s 400. That
10
+ * is why `readPoolStatus` (ceph-pool-wire.ts) folds every failure the same way `ops.read` always
11
+ * did, and why `confirmAbsent` (ceph-pool-settle.ts) asks a SECOND, different question — does
12
+ * the index list this name — before a create is allowed to run. `fakePve` (fake-pve.ts) cannot
13
+ * answer anything but HTTP 200, so this file builds its own stub the way
14
+ * zfs-pool-read-failure.test.ts does, to answer the measured 500 directly.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Layer from 'effect/Layer';
20
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
21
+ import { engineOver } from '../verify/fake-engine.ts';
22
+ import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
23
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
24
+
25
+ const NODE = 'node-b';
26
+ const NAME = 'newpool';
27
+ const INDEX = `nodes/${NODE}/ceph/pool`;
28
+ const OBJECT = `${INDEX}/${NAME}`;
29
+ const STATUS = `${OBJECT}/status`;
30
+
31
+ /** A generic 500 — the measured absence shape, indistinguishable from any other server error. */
32
+ const absent = () =>
33
+ Response.json(
34
+ { data: null, message: `error with 'osd pool get': mon_cmd failed - unrecognized pool` },
35
+ { status: 500 },
36
+ );
37
+
38
+ /**
39
+ * A cluster the test drives through three states: no pool at all, the pool listed but its status
40
+ * unreadable (the PG-merge guard's own case), and a real pool. `writes` records every non-GET
41
+ * call the same way `fakePve.writes()` does, for the same assertions.
42
+ */
43
+ const cluster = () => {
44
+ const writes: string[] = [];
45
+ let indexRow: Record<string, unknown> | undefined;
46
+ let statusRow: Record<string, unknown> | undefined;
47
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
48
+ const request =
49
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
50
+ const url = new URL(request.url);
51
+ if (url.pathname.includes('/creds/')) {
52
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
53
+ return Response.json({ data, lease_duration: 0 });
54
+ }
55
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
56
+ if (request.method !== 'GET') writes.push(`${request.method} ${wire}`);
57
+ if (wire === INDEX && request.method === 'GET') {
58
+ return Response.json({ data: indexRow === undefined ? [] : [indexRow] });
59
+ }
60
+ if (wire === INDEX && request.method === 'POST') {
61
+ indexRow = { pool_name: NAME };
62
+ statusRow = {
63
+ application_list: [],
64
+ crush_rule: 'replicated_rule',
65
+ id: 9,
66
+ min_size: 2,
67
+ name: NAME,
68
+ pg_autoscale_mode: 'on',
69
+ pg_num: 128,
70
+ size: 3,
71
+ };
72
+ return Response.json({ data: 'UPID:node-b:fake:createpool' });
73
+ }
74
+ if (wire === STATUS && request.method === 'GET') {
75
+ return statusRow === undefined ? absent() : Response.json({ data: statusRow });
76
+ }
77
+ if (wire === OBJECT && request.method === 'DELETE') {
78
+ indexRow = undefined;
79
+ statusRow = undefined;
80
+ return Response.json({ data: 'UPID:node-b:fake:destroypool' });
81
+ }
82
+ return Response.json({ data: null });
83
+ };
84
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
85
+ return {
86
+ layer: FetchHttpClient.layer.pipe(
87
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
88
+ ),
89
+ listAsIfPresent: () => {
90
+ indexRow = { pool_name: NAME };
91
+ },
92
+ writes,
93
+ };
94
+ };
95
+
96
+ const declared = () =>
97
+ ProxmoxCephPool(NAME, {
98
+ crush_rule: 'replicated_rule',
99
+ min_size: 2,
100
+ name: NAME,
101
+ node: NODE,
102
+ pg_autoscale_mode: 'on',
103
+ size: 3,
104
+ target: FAKE_TARGET,
105
+ });
106
+
107
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
108
+ engineOver(ProxmoxCephPoolProvider().pipe(Layer.provideMerge(fake.layer)));
109
+
110
+ describe('a genuinely new pool', () => {
111
+ test('creates once confirmAbsent finds the index empty, and settle confirms the match', async () => {
112
+ const fake = cluster();
113
+ await withoutBao(async () => {
114
+ const engine = engineFor(fake);
115
+ expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
116
+ });
117
+ expect(fake.writes).toEqual([`POST ${INDEX}`]);
118
+ });
119
+ });
120
+
121
+ describe("the PG-merge guard: the index lists the pool but its status can't be read", () => {
122
+ test('refuses the create rather than risking a PG merge on a live pool', async () => {
123
+ const fake = cluster();
124
+ fake.listAsIfPresent(); // index says present; statusRow stays undefined -> every read 500s
125
+ await withoutBao(async () => {
126
+ const engine = engineFor(fake);
127
+ await expect(engine.deploy(declared())).rejects.toBeDefined();
128
+ });
129
+ // ⛔ THE ASSERTION THAT MATTERS: no POST ever reached the fake cluster.
130
+ expect(fake.writes).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe('destroying a pool', () => {
135
+ test('RemovalPolicy.destroy() then undeclaring sends exactly one DELETE', async () => {
136
+ const fake = cluster();
137
+ await withoutBao(async () => {
138
+ const engine = engineFor(fake);
139
+ expect(await engine.deploy(declared())).toEqual({ [NAME]: 'create' });
140
+ await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
141
+ await engine.deploy(Effect.void);
142
+ });
143
+ expect(fake.writes).toEqual([`POST ${INDEX}`, `DELETE ${OBJECT}`]);
144
+ });
145
+ });
@@ -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(
@@ -1,124 +0,0 @@
1
- /**
2
- * Exercises `@distilled.cloud/proxmox`'s `awaitTask` (the async-task
3
- * 200-trap — see that package's `src/protocol.ts` header) against a FAKE
4
- * `HttpClient`, no live PVE node involved.
5
- *
6
- * ⚠️ THIS PACKAGE IS CONSUMED VIA THE INTERIM-PACKAGE ALIAS
7
- * (`packages/alchemy/package.json`'s `dependencies`:
8
- * `"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0"`)
9
- * — see `packages/alchemy/docs/distilled-interim.md`. Nothing in
10
- * `packages/alchemy/src/proxmox/*` (the existing 98-resource family) is
11
- * touched or migrated onto it here; this file is standalone proof the
12
- * package works end to end, ahead of migrating any hand-written resource
13
- * onto it (a later PR).
14
- */
15
- import { describe, expect, test } from 'bun:test';
16
- import * as Effect from 'effect/Effect';
17
- import * as Layer from 'effect/Layer';
18
- import * as HttpClient from 'effect/unstable/http/HttpClient';
19
- import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
20
- import * as Proxmox from '@distilled.cloud/proxmox';
21
-
22
- const credentials = Proxmox.credentials({
23
- tokenId: 'root@pam!test',
24
- secret: 'test-secret',
25
- baseUrl: 'https://pve.test:8006',
26
- });
27
-
28
- /** A fake PVE that answers `running` (no `exitstatus`) `pendingPolls` times, then `finalBody`. */
29
- const fakePve = (pendingPolls: number, finalBody: Record<string, unknown>) => {
30
- let calls = 0;
31
- return Layer.succeed(
32
- HttpClient.HttpClient,
33
- HttpClient.make((request) =>
34
- Effect.sync(() => {
35
- calls++;
36
- const body =
37
- calls <= pendingPolls
38
- ? {
39
- status: 'running',
40
- id: 'qmstart',
41
- node: 'pve1',
42
- pid: 100,
43
- pstart: 1,
44
- starttime: 1,
45
- type: 'qmstart',
46
- upid: 'UPID:pve1:...',
47
- user: 'root@pam',
48
- }
49
- : finalBody;
50
- return HttpClientResponse.fromWeb(
51
- request,
52
- new Response(JSON.stringify({ data: body }), { status: 200 }),
53
- );
54
- }),
55
- ),
56
- );
57
- };
58
-
59
- const runAwaitTask = (pendingPolls: number, finalBody: Record<string, unknown>) =>
60
- Effect.runPromise(
61
- Proxmox.awaitTask(
62
- { node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
63
- { pollInterval: '5 millis' },
64
- ).pipe(Effect.provide(Layer.mergeAll(fakePve(pendingPolls, finalBody), credentials))),
65
- );
66
-
67
- const OK_STATUS = {
68
- status: 'stopped',
69
- exitstatus: 'OK',
70
- id: 'qmstart',
71
- node: 'pve1',
72
- pid: 100,
73
- pstart: 1,
74
- starttime: 1,
75
- type: 'qmstart',
76
- upid: 'UPID:pve1:...',
77
- user: 'root@pam',
78
- };
79
-
80
- describe("awaitTask (against a fake PVE, per protocol.ts's async-task trap)", () => {
81
- test("resolves once exitstatus is exactly OK, after polling through 'running'", async () => {
82
- const result = await runAwaitTask(2, OK_STATUS);
83
- expect(result.exitstatus).toBe('OK');
84
- expect(result.status).toBe('stopped');
85
- });
86
-
87
- test('resolves immediately when the task is already done on the first poll', async () => {
88
- const result = await runAwaitTask(0, OK_STATUS);
89
- expect(result.exitstatus).toBe('OK');
90
- });
91
-
92
- test('fails with ProxmoxTaskFailed for any exitstatus other than exactly OK', async () => {
93
- const failed = await Effect.runPromise(
94
- Proxmox.awaitTask(
95
- { node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
96
- { pollInterval: '5 millis' },
97
- ).pipe(
98
- Effect.provide(
99
- Layer.mergeAll(fakePve(0, { ...OK_STATUS, exitstatus: 'job errors' }), credentials),
100
- ),
101
- Effect.flip,
102
- ),
103
- );
104
- expect(failed).toBeInstanceOf(Proxmox.ProxmoxTaskFailed);
105
- expect(failed).toMatchObject({ node: 'pve1', exitstatus: 'job errors' });
106
- });
107
-
108
- // ⛔ "OK (warnings)" is NOT a success — see task.ts's exact-equality note.
109
- test("fails (does not swallow) 'OK (warnings)'", async () => {
110
- const failed = await Effect.runPromise(
111
- Proxmox.awaitTask(
112
- { node: 'pve1', upid: 'UPID:pve1:00000001:00000001:00000001:qmstart:100:root@pam:' },
113
- { pollInterval: '5 millis' },
114
- ).pipe(
115
- Effect.provide(
116
- Layer.mergeAll(fakePve(0, { ...OK_STATUS, exitstatus: 'OK (warnings)' }), credentials),
117
- ),
118
- Effect.flip,
119
- ),
120
- );
121
- expect(failed).toBeInstanceOf(Proxmox.ProxmoxTaskFailed);
122
- expect(failed).toMatchObject({ exitstatus: 'OK (warnings)' });
123
- });
124
- });