@homeflare/alchemy 0.19.0 → 0.20.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 (39) hide show
  1. package/dist/linux/index.js +140 -93
  2. package/dist/linux/index.js.map +11 -10
  3. package/dist/linux/region.d.ts +11 -0
  4. package/dist/linux/region.d.ts.map +1 -1
  5. package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
  6. package/dist/linux/remote-file-plan.d.ts +22 -0
  7. package/dist/linux/remote-file-plan.d.ts.map +1 -1
  8. package/dist/linux/remote-file.d.ts +4 -1
  9. package/dist/linux/remote-file.d.ts.map +1 -1
  10. package/dist/linux/unit-handlers.d.ts.map +1 -1
  11. package/dist/linux/unit-lifecycle.d.ts +1 -5
  12. package/dist/linux/unit-lifecycle.d.ts.map +1 -1
  13. package/dist/linux/unit-preflight.d.ts +50 -0
  14. package/dist/linux/unit-preflight.d.ts.map +1 -0
  15. package/dist/proxmox/index.d.ts +15 -3
  16. package/dist/proxmox/index.d.ts.map +1 -1
  17. package/dist/proxmox/index.js +4 -1
  18. package/dist/proxmox/index.js.map +1 -1
  19. package/docs/adopted-deploys.md +13 -0
  20. package/docs/linux-host.md +11 -1
  21. package/docs/provider-standard.md +138 -0
  22. package/docs/upstream-conformance.md +164 -0
  23. package/package.json +1 -1
  24. package/src/linux/region.ts +15 -0
  25. package/src/linux/remote-file-lifecycle.test.ts +0 -88
  26. package/src/linux/remote-file-lifecycle.ts +25 -7
  27. package/src/linux/remote-file-plan.ts +37 -1
  28. package/src/linux/remote-file-region.test.ts +181 -0
  29. package/src/linux/remote-file.ts +4 -1
  30. package/src/linux/unit-handlers.ts +12 -5
  31. package/src/linux/unit-lifecycle.ts +12 -30
  32. package/src/linux/unit-preflight.test.ts +128 -0
  33. package/src/linux/unit-preflight.ts +116 -0
  34. package/src/linux/unit-rename.test.ts +76 -0
  35. package/src/proxmox/ceph-adopt.test.ts +210 -0
  36. package/src/proxmox/constraints-forms.test.ts +14 -6
  37. package/src/proxmox/constraints-node-forms.test.ts +12 -3
  38. package/src/proxmox/index.test.ts +7 -4
  39. package/src/proxmox/index.ts +15 -3
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The three Ceph Resources the barrel exports since 2026-09-22 — `CephDaemon`, `CephFs`,
3
+ * `CephOsd` — adopted and then undeclared through Alchemy's own Plan and Apply, over a fake cluster.
4
+ *
5
+ * ★ WHY THESE THREE COULD JOIN THE BARREL AT ALL: each is adopt-only BY SHAPE, and this file is
6
+ * the proof rather than the claim. CephDaemon and CephFs compare nothing (`matches: () => true`),
7
+ * so a present object is always `noop` and never a replace; CephOsd reads the CRUSH tree and
8
+ * POSTs only when `dev` is declared, which nothing here does. All three default to `retain`.
9
+ * ⛔ WHAT A MISTAKE HERE WOULD COST ON A REAL CLUSTER: a mon DELETE loses a quorum member, a CephFS
10
+ * DELETE breaks every mount, an OSD DELETE starts a rebalance. So every test ends on the same
11
+ * assertion — `fake.writes()` is empty — and one row proves the harness can still see a refusal.
12
+ * ★ THE FIXTURE IS C1, THE KIT'S REFERENCE CLUSTER (docs/proxmox.md): three nodes, a mon, mgr and
13
+ * mds on each, one CephFS, two ssd OSDs per node — shapes and counts measured, names and
14
+ * addresses placeholders (RFC 5737). The rows carry only the keys the families read.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Layer from 'effect/Layer';
19
+ import { engineOver } from '../verify/fake-engine.ts';
20
+ import {
21
+ type CephDaemonKind,
22
+ ProxmoxCephDaemon,
23
+ ProxmoxCephDaemonProvider,
24
+ } from './ceph-daemon.ts';
25
+ import { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
26
+ import { ProxmoxCephOsd, ProxmoxCephOsdProvider } from './ceph-osd.ts';
27
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
28
+
29
+ const NODES = ['node-b', 'node-c', 'node-d'] as const;
30
+ const KINDS: readonly CephDaemonKind[] = ['mon', 'mgr', 'mds'];
31
+ const ip = (node: string) => `198.51.100.${String(12 + NODES.indexOf(node as never))}`;
32
+
33
+ /**
34
+ * One collection answer per kind. ⚠️ Every node answers the SAME cluster-wide list — measured on
35
+ * C1, and the reason ceph-daemon-form.ts matches rows by `name` and never by position.
36
+ */
37
+ const daemons: Record<CephDaemonKind, Record<string, unknown>[]> = {
38
+ mds: NODES.map((node) => ({
39
+ addr: `${ip(node)}:6801/1`,
40
+ ceph_version_short: '20.2.2',
41
+ host: node,
42
+ name: node,
43
+ rank: node === 'node-c' ? 0 : -1,
44
+ service: true,
45
+ standby_replay: false,
46
+ state: node === 'node-c' ? 'up:active' : 'up:standby',
47
+ ...(node === 'node-c' ? { fs_name: 'cephfs-c1' } : {}),
48
+ })),
49
+ mgr: NODES.map((node) => ({
50
+ addr: ip(node),
51
+ host: node,
52
+ name: node,
53
+ service: true,
54
+ state: node === 'node-b' ? 'active' : 'standby',
55
+ })),
56
+ mon: NODES.map((node, rank) => ({
57
+ addr: `${ip(node)}:6789/0`,
58
+ host: node,
59
+ name: node,
60
+ quorum: 1,
61
+ rank,
62
+ service: true,
63
+ state: 'running',
64
+ })),
65
+ };
66
+
67
+ const fs = [
68
+ {
69
+ data_pool: 'cephfs-c1_data',
70
+ data_pools: ['cephfs-c1_data'],
71
+ metadata_pool: 'cephfs-c1_metadata',
72
+ metadata_pool_id: 7,
73
+ name: 'cephfs-c1',
74
+ },
75
+ ];
76
+
77
+ /** osd.{2,3} on node-b, osd.{1,4} on node-c, osd.{0,5} on node-d — C1's placement. */
78
+ const OSDS: readonly (readonly [number, string])[] = [
79
+ [2, 'node-b'],
80
+ [3, 'node-b'],
81
+ [1, 'node-c'],
82
+ [4, 'node-c'],
83
+ [0, 'node-d'],
84
+ [5, 'node-d'],
85
+ ];
86
+
87
+ /** ⚠️ The tree spells ids as STRINGS (`"2"`), exactly as measured; ceph-osd-tree.ts narrows them. */
88
+ const tree = {
89
+ flags: 'sortbitwise,recovery_deletes,purged_snapdirs,pglog_hardlimit',
90
+ root: {
91
+ children: [
92
+ {
93
+ children: NODES.map((host) => ({
94
+ children: OSDS.filter(([, on]) => on === host).map(([id]) => ({
95
+ device_class: 'ssd',
96
+ host,
97
+ id: String(id),
98
+ in: 1,
99
+ leaf: 1,
100
+ name: `osd.${String(id)}`,
101
+ osdtype: 'bluestore',
102
+ status: 'up',
103
+ type: 'osd',
104
+ })),
105
+ name: host,
106
+ type: 'host',
107
+ })),
108
+ name: 'default',
109
+ type: 'root',
110
+ },
111
+ ],
112
+ leaf: 0,
113
+ },
114
+ };
115
+
116
+ /** GETs answer from the fixture; anything else is recorded and answered like a task that ran. */
117
+ const cluster = () =>
118
+ fakePve((call: PveCall) => {
119
+ if (call.method !== 'GET') return 'UPID:node-b:fake';
120
+ const hit = /^nodes\/[^/]+\/ceph\/(mon|mgr|mds|fs|osd)$/.exec(call.path)?.[1];
121
+ if (hit === 'fs') return fs;
122
+ if (hit === 'osd') return tree;
123
+ return hit === undefined ? undefined : daemons[hit as CephDaemonKind];
124
+ });
125
+
126
+ const providers = Layer.mergeAll(
127
+ ProxmoxCephDaemonProvider(),
128
+ ProxmoxCephFsProvider(),
129
+ ProxmoxCephOsdProvider(),
130
+ );
131
+
132
+ /**
133
+ * Every live Ceph object of the three families, declared the way a consuming stack would: each
134
+ * daemon on the node it runs on, the filesystem asked of any node, each OSD's failure domain and
135
+ * device class asserted.
136
+ */
137
+ const everything = (osdHost: (host: string) => string = (host) => host) =>
138
+ Effect.all([
139
+ ...KINDS.flatMap((kind) =>
140
+ NODES.map((node) =>
141
+ ProxmoxCephDaemon(`${kind}-${node}`, { kind, node, target: FAKE_TARGET }),
142
+ ),
143
+ ),
144
+ ProxmoxCephFs('cephfs-c1', { name: 'cephfs-c1', node: 'node-b', target: FAKE_TARGET }),
145
+ ...OSDS.map(([osdid, host]) =>
146
+ ProxmoxCephOsd(`osd-${String(osdid)}`, {
147
+ device_class: 'ssd',
148
+ host: osdHost(host),
149
+ node: 'node-b',
150
+ osdid,
151
+ target: FAKE_TARGET,
152
+ }),
153
+ ),
154
+ ]);
155
+
156
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
157
+ engineOver(providers.pipe(Layer.provideMerge(fake.layer)));
158
+
159
+ describe('adopting the live Ceph daemons, filesystem and OSDs', () => {
160
+ test('every row is noop to the verifier, adopted by the deploy, and only read', async () => {
161
+ const fake = cluster();
162
+ await withoutBao(async () => {
163
+ const engine = engineFor(fake);
164
+ const report = await engine.verify(everything());
165
+ expect(report.rows).toHaveLength(16);
166
+ for (const row of report.rows) {
167
+ expect(row).toMatchObject({ changed: [], diff: 'noop', ok: true, planned: 'adopted' });
168
+ }
169
+ const planned = await engine.deploy(everything());
170
+ expect(new Set(Object.values(planned))).toEqual(new Set(['adopted']));
171
+ expect(Object.keys(planned)).toHaveLength(16);
172
+ });
173
+ expect(fake.writes()).toEqual([]);
174
+ });
175
+
176
+ /**
177
+ * ⛔ THE ONE THAT MATTERS MOST. `retain` is the whole reason a deleted line is not a lost mon, a
178
+ * destroyed filesystem or a pulled replica; Alchemy plans the removal `orphaned` and must drop
179
+ * only its own state row.
180
+ */
181
+ test('undeclaring all sixteen drops state rows and sends no DELETE', async () => {
182
+ const fake = cluster();
183
+ await withoutBao(async () => {
184
+ const engine = engineFor(fake);
185
+ await engine.deploy(everything());
186
+ expect(engine.stored()).toContain('mon-node-b');
187
+ // ⚠️ `deploy` reports planned resources only; a removal lives in the plan's `deletions`, so
188
+ // the proof is the state store emptying and the cluster seeing no write.
189
+ expect(await engine.deploy(Effect.void)).toEqual({});
190
+ for (const fqn of ['mon-node-b', 'mds-node-c', 'cephfs-c1', 'osd-0']) {
191
+ expect(engine.stored()).not.toContain(`"${fqn}"`);
192
+ }
193
+ });
194
+ expect(fake.writes()).toEqual([]);
195
+ });
196
+
197
+ test('an OSD asserted on the wrong host fails the verifier and the deploy, and writes nothing', async () => {
198
+ const fake = cluster();
199
+ const moved = (host: string) => (host === 'node-b' ? 'node-c' : host);
200
+ await withoutBao(async () => {
201
+ const engine = engineFor(fake);
202
+ const report = await engine.verify(everything(moved));
203
+ const wrong = report.rows.filter((row) => !row.ok).map((row) => row.fqn);
204
+ expect(wrong.toSorted()).toEqual(['osd-2', 'osd-3']);
205
+ expect(report.rows.find((row) => row.fqn === 'osd-2')?.changed).toEqual(['host']);
206
+ await expect(engine.deploy(everything(moved))).rejects.toBeDefined();
207
+ });
208
+ expect(fake.writes()).toEqual([]);
209
+ });
210
+ });
@@ -58,17 +58,25 @@ describe('Proxmox.ApiToken plans clean against POST /access/users/{userid}/token
58
58
  });
59
59
 
60
60
  describe('Proxmox.MetricServer plans clean against POST /cluster/metrics/server/{id}', () => {
61
- /** The estate's own row: InfluxDB v2 over https, in a section named `macmini`. */
61
+ /**
62
+ * The estate's own row, SHAPED: InfluxDB v2 over https with an api-path-prefix, an
63
+ * organization and a bucket, every value a placeholder.
64
+ *
65
+ * ⛔ SHAPE ONLY, FOR THE REASON lxc-harness.ts GIVES. `src` ships in the npm tarball and this
66
+ * repository is public, so a real metrics host, cluster name or organization here would be
67
+ * estate topology published forever in the git history. The proof is about which KEYS the
68
+ * create form sends and which bounds they face; the strings carry none of it.
69
+ */
62
70
  const declared = {
63
- 'api-path-prefix': '/tb4/',
71
+ 'api-path-prefix': '/cluster-a/',
64
72
  bucket: 'pve',
65
73
  disable: false,
66
- id: 'macmini',
74
+ id: 'metrics-example',
67
75
  influxdbproto: 'https',
68
76
  'max-body-size': 10_000_000,
69
- organization: 'homeflare',
77
+ organization: 'example',
70
78
  port: 443,
71
- server: 'metrics.homeflare.dev',
79
+ server: 'metrics.example.invalid',
72
80
  target: TARGET,
73
81
  type: 'influxdb',
74
82
  'verify-certificate': true,
@@ -102,7 +110,7 @@ describe('Proxmox.MetricServer plans clean against POST /cluster/metrics/server/
102
110
  });
103
111
 
104
112
  describe('Proxmox.NotificationTarget plans clean per family', () => {
105
- /** The estate's only target: the shipped `mail-to-root`, edited. sendmail requires only a name. */
113
+ /** The shipped `mail-to-root`, edited — the shape every estate has. sendmail needs only a name. */
106
114
  test('the live sendmail declaration has no violations', () => {
107
115
  expect(
108
116
  onCreate(
@@ -50,7 +50,12 @@ describe('the Ceph families plan clean against their own creates', () => {
50
50
  expect(
51
51
  onCreate(
52
52
  'pve:POST /nodes/{node}/ceph/fs/{name}',
53
- cephFsCreateForm({ 'add-storage': true, name: 'cephfs-c1', node: 'n2', target: TARGET }),
53
+ cephFsCreateForm({
54
+ 'add-storage': true,
55
+ name: 'cephfs-example',
56
+ node: 'n2',
57
+ target: TARGET,
58
+ }),
54
59
  ),
55
60
  ).toEqual([]);
56
61
  expect(constraintsFor('pve:POST /nodes/{node}/ceph/fs/{name}')['pg_num']).toMatchObject({
@@ -69,9 +74,13 @@ describe('the Ceph families plan clean against their own creates', () => {
69
74
  expect(constraintsFor('pve:POST /nodes/{node}/ceph/osd')['dev']?.required).toBe(true);
70
75
  });
71
76
 
72
- /** The estate's own pools: `cephtb4` and the two `cephfs-tb4_*` halves, size 3 / min_size 2. */
77
+ /**
78
+ * The estate's own pool set, SHAPED: an RBD pool and a CephFS data/metadata pair, size 3 and
79
+ * min_size 2, names replaced. ⛔ `src` ships in the npm tarball and this repository is public —
80
+ * lxc-harness.ts has the rule. The proof is the keys and the bounds, never the strings.
81
+ */
73
82
  test('the live CephPool declarations have no violations', () => {
74
- for (const name of ['cephtb4', 'cephfs-tb4_data', 'cephfs-tb4_metadata']) {
83
+ for (const name of ['rbd-example', 'cephfs-example_data', 'cephfs-example_metadata']) {
75
84
  expect(
76
85
  onCreate(
77
86
  'pve:POST /nodes/{node}/ceph/pool',
@@ -4,6 +4,9 @@
4
4
  * ⛔ Vm stays Provider-only. NodeNetwork/NetworkApply stay off the barrel — they stage
5
5
  * interfaces.new. ★ Lxc is a Resource since 2026-09-21: adopt-first, refuse-not-replace,
6
6
  * retain on destroy (lxc.ts).
7
+ * ★ CephDaemon, CephFs and CephOsd are Resources since 2026-09-22: adopt-only by shape and retain
8
+ * on destroy (ceph-adopt.test.ts). ⛔ CephFlag stays Provider-only: a declared flag reasserts a
9
+ * maintenance toggle on every deploy (ceph-flag.ts).
7
10
  */
8
11
  import { expect, test } from 'bun:test';
9
12
  import { PbsDatastore, ProxmoxStorage } from './index.ts';
@@ -19,6 +22,9 @@ const resourceExports = [
19
22
  'PbsVerifyJob',
20
23
  'ProxmoxAcl',
21
24
  'ProxmoxBackupJob',
25
+ 'ProxmoxCephDaemon',
26
+ 'ProxmoxCephFs',
27
+ 'ProxmoxCephOsd',
22
28
  'ProxmoxGroup',
23
29
  'ProxmoxHaResource',
24
30
  'ProxmoxHaRule',
@@ -50,14 +56,11 @@ test('the provisioning baseline is on the barrel: the list, the declaration, the
50
56
  expect(barrel.provisionBootstrap()).toStartWith('#!/bin/sh\n');
51
57
  });
52
58
 
53
- test('QEMU and NIC apply stay Provider-only', () => {
59
+ test('QEMU, NIC apply and the Ceph flag stay Provider-only', () => {
54
60
  expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
55
61
  expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
56
62
  expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
57
63
  expect(src).not.toMatch(/export \{ ProxmoxApiToken[, }]/);
58
64
  expect(src).not.toMatch(/export \{ ProxmoxZfsPool[, }]/);
59
- expect(src).not.toMatch(/export \{ ProxmoxCephOsd[, }]/);
60
- expect(src).not.toMatch(/export \{ ProxmoxCephFs[, }]/);
61
- expect(src).not.toMatch(/export \{ ProxmoxCephDaemon[, }]/);
62
65
  expect(src).not.toMatch(/export \{ ProxmoxCephFlag[, }]/);
63
66
  });
@@ -21,6 +21,18 @@
21
21
  * ★ THE VENDOR CONSTRAINT READER JOINED 2026-09-22 (codegen/README.md). `constraintsFor` and
22
22
  * `violations` are exported so a CONSUMING stack can assert its own declarations offline — the
23
23
  * `deploy:pbs` that failed on a 128-character comment had no way to ask that question.
24
+ * ★ THE CEPH DAEMON, FILESYSTEM AND OSD RESOURCES JOINED 2026-09-22, BECAUSE EACH IS ALREADY
25
+ * ADOPT-ONLY BY SHAPE and a live cluster measured that way was waiting on nothing but this line.
26
+ * `ProxmoxCephDaemon` (mon/mgr/mds) and `ProxmoxCephFs` compare nothing, so no edit can plan a
27
+ * replace; `ProxmoxCephOsd` never POSTs unless `dev` is declared. All three default to `retain`,
28
+ * so deleting a declaration drops a state row and leaves the quorum member, the filesystem or
29
+ * the replica alone. ceph-adopt.test.ts pins adopting each as reads only, and removing each as
30
+ * no DELETE, through Alchemy's own Plan and Apply.
31
+ * ⛔ `ProxmoxCephFlag` STAYS PROVIDER-ONLY, AND ON PURPOSE. A flag is a hand on the brake, not
32
+ * configuration: a declared `noout: false` clears an operator's `noout` on EVERY deploy,
33
+ * including one run for an unrelated resource in the middle of a maintenance window
34
+ * (ceph-flag.ts has the whole argument). A stack that wants a flag set must reach for the
35
+ * Provider deliberately; the barrel does not offer the sentence by default.
24
36
  * ★ Anything else unlisted is still reachable by path if you genuinely need
25
37
  * it — that is a deliberate, visible act rather than an accident of barrelling.
26
38
  */
@@ -28,10 +40,10 @@ export { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
28
40
  export { type AlertmanagerBodyOptions, alertmanagerAlertBody } from './alertmanager-body.ts';
29
41
  export { ProxmoxApiTokenProvider } from './api-token.ts';
30
42
  export { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
31
- export { ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
43
+ export { ProxmoxCephDaemon, ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
32
44
  export { ProxmoxCephFlagProvider } from './ceph-flag.ts';
33
- export { ProxmoxCephFsProvider } from './ceph-fs.ts';
34
- export { ProxmoxCephOsdProvider } from './ceph-osd.ts';
45
+ export { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
46
+ export { ProxmoxCephOsd, ProxmoxCephOsdProvider } from './ceph-osd.ts';
35
47
  export { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
36
48
  export { constraintsFor, formViolations } from './constraint-guard.ts';
37
49
  export {