@homeflare/alchemy 0.19.1 → 0.21.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 (198) hide show
  1. package/README.md +25 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.js +173 -120
  24. package/dist/launchd/index.js.map +12 -10
  25. package/dist/launchd/local-runner.d.ts.map +1 -1
  26. package/dist/launchd/runner.d.ts +17 -2
  27. package/dist/launchd/runner.d.ts.map +1 -1
  28. package/dist/launchd/sudo-runner.d.ts +11 -0
  29. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/index.js +37 -11
  31. package/dist/linux/index.js.map +10 -10
  32. package/dist/linux/region.d.ts +11 -0
  33. package/dist/linux/region.d.ts.map +1 -1
  34. package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
  35. package/dist/linux/remote-file-plan.d.ts +22 -0
  36. package/dist/linux/remote-file-plan.d.ts.map +1 -1
  37. package/dist/linux/remote-file.d.ts +4 -1
  38. package/dist/linux/remote-file.d.ts.map +1 -1
  39. package/dist/linux/sudo-listing.d.ts +47 -0
  40. package/dist/linux/sudo-listing.d.ts.map +1 -0
  41. package/dist/linux/unit-handlers.d.ts.map +1 -1
  42. package/dist/linux/unit-preflight.d.ts +10 -1
  43. package/dist/linux/unit-preflight.d.ts.map +1 -1
  44. package/dist/proxmox/credential-literals.d.ts +1 -1
  45. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  46. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  47. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  48. package/dist/proxmox/generated/pbs.d.ts +7 -0
  49. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  50. package/dist/proxmox/generated/pve.d.ts +7 -0
  51. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  52. package/dist/proxmox/index.d.ts +16 -4
  53. package/dist/proxmox/index.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +5 -2
  55. package/dist/proxmox/index.js.map +9 -9
  56. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  57. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  59. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  60. package/dist/release/archive.d.ts +11 -0
  61. package/dist/release/archive.d.ts.map +1 -0
  62. package/dist/release/binary-claim.d.ts +53 -0
  63. package/dist/release/binary-claim.d.ts.map +1 -0
  64. package/dist/release/binary-diff.d.ts +38 -0
  65. package/dist/release/binary-diff.d.ts.map +1 -0
  66. package/dist/release/binary-form.d.ts +118 -0
  67. package/dist/release/binary-form.d.ts.map +1 -0
  68. package/dist/release/binary-lifecycle.d.ts +55 -0
  69. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  70. package/dist/release/binary-read.d.ts +11 -0
  71. package/dist/release/binary-read.d.ts.map +1 -0
  72. package/dist/release/binary.d.ts +68 -0
  73. package/dist/release/binary.d.ts.map +1 -0
  74. package/dist/release/catalog.d.ts +76 -0
  75. package/dist/release/catalog.d.ts.map +1 -0
  76. package/dist/release/checksums.d.ts +22 -0
  77. package/dist/release/checksums.d.ts.map +1 -0
  78. package/dist/release/declared-pins.d.ts +4 -0
  79. package/dist/release/declared-pins.d.ts.map +1 -0
  80. package/dist/release/download.d.ts +44 -0
  81. package/dist/release/download.d.ts.map +1 -0
  82. package/dist/release/fake-deploy.d.ts +48 -0
  83. package/dist/release/fake-deploy.d.ts.map +1 -0
  84. package/dist/release/fake-engine.d.ts +8 -0
  85. package/dist/release/fake-engine.d.ts.map +1 -0
  86. package/dist/release/fake-install.d.ts +39 -0
  87. package/dist/release/fake-install.d.ts.map +1 -0
  88. package/dist/release/fake-provider.d.ts +58 -0
  89. package/dist/release/fake-provider.d.ts.map +1 -0
  90. package/dist/release/fake-release.d.ts +95 -0
  91. package/dist/release/fake-release.d.ts.map +1 -0
  92. package/dist/release/index.d.ts +24 -0
  93. package/dist/release/index.d.ts.map +1 -0
  94. package/dist/release/index.js +1365 -0
  95. package/dist/release/index.js.map +35 -0
  96. package/dist/release/providers.d.ts +19 -0
  97. package/dist/release/providers.d.ts.map +1 -0
  98. package/dist/release/refused.d.ts +39 -0
  99. package/dist/release/refused.d.ts.map +1 -0
  100. package/dist/release/tar.d.ts +14 -0
  101. package/dist/release/tar.d.ts.map +1 -0
  102. package/dist/release/victoria.d.ts +36 -0
  103. package/dist/release/victoria.d.ts.map +1 -0
  104. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  105. package/docs/adopted-deploys.md +13 -0
  106. package/docs/linux-host.md +5 -1
  107. package/docs/provider-standard.md +138 -0
  108. package/docs/release-binary-catalogs.md +113 -0
  109. package/docs/release-binary-measured.md +163 -0
  110. package/docs/release-binary-refusals.md +57 -0
  111. package/docs/release-binary-upstream.md +163 -0
  112. package/docs/release-binary.md +154 -0
  113. package/docs/upstream-conformance.md +164 -0
  114. package/package.json +5 -1
  115. package/src/caddy/access-forward-auth.test.ts +63 -0
  116. package/src/caddy/access-forward-auth.ts +120 -0
  117. package/src/caddy/index.ts +10 -0
  118. package/src/caddy/providers.test.ts +4 -0
  119. package/src/cloudflare/mcp-server-api.ts +294 -0
  120. package/src/cloudflare/mcp-server-form.ts +219 -0
  121. package/src/index.ts +1 -0
  122. package/src/launchd/file-converge.ts +233 -0
  123. package/src/launchd/file-identity.ts +69 -0
  124. package/src/launchd/host-file-alias.test.ts +65 -0
  125. package/src/launchd/host-file-form.ts +2 -1
  126. package/src/launchd/host-file-lifecycle.ts +25 -144
  127. package/src/launchd/host-file.ts +8 -2
  128. package/src/launchd/local-runner.test.ts +12 -1
  129. package/src/launchd/local-runner.ts +2 -0
  130. package/src/launchd/runner.ts +17 -2
  131. package/src/launchd/sudo-runner.ts +11 -0
  132. package/src/linux/region.ts +15 -0
  133. package/src/linux/remote-file-lifecycle.test.ts +0 -88
  134. package/src/linux/remote-file-lifecycle.ts +25 -7
  135. package/src/linux/remote-file-plan.ts +37 -1
  136. package/src/linux/remote-file-region.test.ts +181 -0
  137. package/src/linux/remote-file.ts +4 -1
  138. package/src/linux/sudo-listing.ts +122 -0
  139. package/src/linux/unit-handlers.ts +12 -5
  140. package/src/linux/unit-preflight.ts +20 -4
  141. package/src/linux/unit-rename.test.ts +76 -0
  142. package/src/proxmox/ceph-adopt.test.ts +210 -0
  143. package/src/proxmox/credential-literals.ts +1 -1
  144. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  145. package/src/proxmox/generated/pbs.ts +7 -0
  146. package/src/proxmox/generated/pve.ts +7 -0
  147. package/src/proxmox/index.test.ts +7 -4
  148. package/src/proxmox/index.ts +16 -4
  149. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  150. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  151. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  152. package/src/proxmox/pbs-notification-target.ts +1 -1
  153. package/src/release/adopt-parity.test.ts +106 -0
  154. package/src/release/apply-pins.test.ts +128 -0
  155. package/src/release/archive.ts +94 -0
  156. package/src/release/binary-alias.test.ts +109 -0
  157. package/src/release/binary-claim.ts +80 -0
  158. package/src/release/binary-claims.test.ts +47 -0
  159. package/src/release/binary-diff.ts +73 -0
  160. package/src/release/binary-form.ts +229 -0
  161. package/src/release/binary-lifecycle.test.ts +90 -0
  162. package/src/release/binary-lifecycle.ts +202 -0
  163. package/src/release/binary-read.ts +55 -0
  164. package/src/release/binary-refusals.test.ts +192 -0
  165. package/src/release/binary-sudo.test.ts +93 -0
  166. package/src/release/binary.ts +181 -0
  167. package/src/release/catalog.test.ts +96 -0
  168. package/src/release/catalog.ts +175 -0
  169. package/src/release/checksums.test.ts +35 -0
  170. package/src/release/checksums.ts +43 -0
  171. package/src/release/declared-pins.ts +39 -0
  172. package/src/release/download.test.ts +138 -0
  173. package/src/release/download.ts +110 -0
  174. package/src/release/error-channel.test.ts +69 -0
  175. package/src/release/fake-deploy.ts +87 -0
  176. package/src/release/fake-engine.ts +50 -0
  177. package/src/release/fake-install.ts +59 -0
  178. package/src/release/fake-provider.ts +68 -0
  179. package/src/release/fake-release.ts +185 -0
  180. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  181. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  182. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  183. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  184. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  185. package/src/release/index.ts +41 -0
  186. package/src/release/plan.test.ts +189 -0
  187. package/src/release/provider-refusals.test.ts +48 -0
  188. package/src/release/providers.test.ts +174 -0
  189. package/src/release/providers.ts +24 -0
  190. package/src/release/recovery-read.test.ts +44 -0
  191. package/src/release/refused.ts +40 -0
  192. package/src/release/tar.test.ts +142 -0
  193. package/src/release/tar.ts +195 -0
  194. package/src/release/victoria.test.ts +108 -0
  195. package/src/release/victoria.ts +154 -0
  196. package/src/{proxmox → secrets}/write-only.ts +6 -0
  197. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  198. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -48,7 +48,7 @@ export const assertMayWrite = (runner: HostRunner, name: string): void => {
48
48
  * ⛔ A MASKED UNIT IS SOMEONE'S DECISION, NOT DRIFT. `systemctl mask` survives reboots and makes
49
49
  * start fail; unmasking here would silently overrule whoever masked it.
50
50
  */
51
- export const assertUsable = (props: SystemdUnitProps, status: UnitStatus): void => {
51
+ export const assertUsable = (props: Pick<SystemdUnitProps, 'name'>, status: UnitStatus): void => {
52
52
  if (status.loadState === 'masked' || status.unitFileState === 'masked') {
53
53
  throw refuse(
54
54
  props.name,
@@ -81,6 +81,24 @@ export const assertUnclaimed = async (
81
81
  }
82
82
  };
83
83
 
84
+ /**
85
+ * The half of a rename check that needs only the NEW NAME: the old unit deletable, the new one
86
+ * writable and not masked.
87
+ * ★ ON ITS OWN it is for the rename unit-handlers.ts `diffHandler` spots while `content` is still
88
+ * an Output. That plan is a delete-first replace too, so a masked target refused only in
89
+ * reconcile would again arrive after the old unit was stopped. The unit file itself cannot be
90
+ * compared until `content` resolves, so `assertUnclaimed` stays reconcile's job there.
91
+ */
92
+ export const assertRenameTarget = async (
93
+ runner: HostRunner,
94
+ old: SystemdUnitAttributes,
95
+ name: string,
96
+ ): Promise<void> => {
97
+ assertMayWrite(runner, old.name);
98
+ assertMayWrite(runner, name);
99
+ assertUsable({ name }, await showUnit(runner, name));
100
+ };
101
+
84
102
  /**
85
103
  * A rename, checked at plan time: the new identity must be valid, writable, not masked and free,
86
104
  * and the old one deletable — before the plan may promise a delete-first replace.
@@ -92,9 +110,7 @@ export const assertReplaceable = async (
92
110
  expect?: string,
93
111
  ): Promise<void> => {
94
112
  assertValid(next, expect);
95
- assertMayWrite(runner, old.name);
96
- assertMayWrite(runner, next.name);
97
- assertUsable(next, await showUnit(runner, next.name));
113
+ await assertRenameTarget(runner, old, next.name);
98
114
  await assertUnclaimed(runner, next);
99
115
  await runner.checkWrite?.(unitPathFor(next), UNIT_WRITE);
100
116
  };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The rename a plan sees while `content` is still an Output. `diffHandler` cannot run `diffUnit`
3
+ * there, but it still plans a delete-first replace — so the refusals that need only the new NAME
4
+ * have to fire in the plan, before Alchemy stops and removes the old unit.
5
+ *
6
+ * ★ The fully resolved rename is unit-preflight.test.ts. 🔴 Found by review (2026-09-22): this
7
+ * branch returned the replace with no check at all.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import * as Effect from 'effect/Effect';
11
+ import { fakeLinuxHost } from './fake-linux-host.ts';
12
+ import { renderUnit } from './unit-form.ts';
13
+ import { diffHandler } from './unit-handlers.ts';
14
+ import { diffUnit, reconcileUnit } from './unit-lifecycle.ts';
15
+
16
+ const NAME = 'hf-example.service';
17
+ const PATH = `/etc/systemd/system/${NAME}`;
18
+ const RENAMED = 'hf-renamed.service';
19
+ const RENAMED_PATH = `/etc/systemd/system/${RENAMED}`;
20
+ const body = (exec: string) =>
21
+ renderUnit([
22
+ { lines: [['Description', 'example']], name: 'Unit' },
23
+ { lines: [['ExecStart', exec]], name: 'Service' },
24
+ { lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
25
+ ]);
26
+
27
+ const props = { content: body('/usr/bin/example'), name: NAME };
28
+ // ★ An Effect is what isResolved() calls unresolved — the stand-in for a rendered digest.
29
+ const pending = { content: Effect.succeed(props.content), name: RENAMED } as never;
30
+
31
+ const running = async () => {
32
+ const fake = fakeLinuxHost({ dirs: { '/etc/systemd/system': 0 } });
33
+ const output = await reconcileUnit(fake.runner, props, undefined);
34
+ return { fake, output, writes: () => fake.calls.filter((call) => call[0] === 'write').length };
35
+ };
36
+
37
+ describe('a rename while content is still an Output', () => {
38
+ test('a free name plans a delete-first replace and writes nothing', async () => {
39
+ const { fake, output, writes } = await running();
40
+ const before = writes();
41
+ const diff = await Effect.runPromise(diffHandler(fake.runner, 'id', pending, output));
42
+ expect(diff).toEqual({ action: 'replace', deleteFirst: true });
43
+ expect(writes()).toBe(before);
44
+ });
45
+
46
+ test('a MASKED target is refused by the plan, with the old unit still running', async () => {
47
+ const { fake, output } = await running();
48
+ fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { masked: true });
49
+ await expect(
50
+ Effect.runPromise(diffHandler(fake.runner, 'id', pending, output)),
51
+ ).rejects.toThrow(/is masked/);
52
+ expect(fake.units.get(NAME)?.active).toBe(true);
53
+ expect(fake.files.has(PATH)).toBe(true);
54
+ });
55
+
56
+ test('an unprivileged runner is refused before anything is removed', async () => {
57
+ const { fake, output } = await running();
58
+ const unprivileged = { ...fake.runner, effectiveUid: () => 1001, privileged: false };
59
+ await expect(
60
+ Effect.runPromise(diffHandler(unprivileged, 'id', pending, output)),
61
+ ).rejects.toThrow(/never calls sudo/);
62
+ expect(fake.units.get(NAME)?.active).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe('a resolved rename', () => {
67
+ test('is read-only: the plan writes nothing, whatever it decided', async () => {
68
+ const { fake, output, writes } = await running();
69
+ const before = writes();
70
+ expect(await diffUnit(fake.runner, { ...props, name: RENAMED }, output)).toEqual({
71
+ action: 'replace',
72
+ deleteFirst: true,
73
+ });
74
+ expect(writes()).toBe(before);
75
+ });
76
+ });
@@ -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
+ });
@@ -23,7 +23,7 @@
23
23
  * it from the template. docs/pbs-notifications.md says the same to the reader.
24
24
  * ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
25
25
  */
26
- import type { FromEnv } from './write-only.ts';
26
+ import type { FromEnv } from '../secrets/write-only.ts';
27
27
 
28
28
  /** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
29
29
  const READS_SECRET = /\{\{[^}]*\bsecrets\./;
@@ -241,4 +241,4 @@ export type NodesNodeDisksZfsPostParams = {
241
241
  export type NodesNodeDisksZfsPostReturn = string;
242
242
 
243
243
  /** GET /nodes/{node}/disks/zfs/{name} — `data` payload after client unwrap. */
244
- export type NodesNodeDisksZfsNameGetReturn = {};
244
+ export type NodesNodeDisksZfsNameGetReturn = Record<string, unknown>;
@@ -13,6 +13,13 @@
13
13
  * ⚠️ 151 request parameters are typed `\`${number}\`` because the vendor calls them
14
14
  * integer or number. They were `string` before, which accepted 'banana'. A caller holding a
15
15
  * number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
16
+ *
17
+ * ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pve.ts`, MEANING SOMETHING ELSE. Both
18
+ * products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
19
+ * exists on each side — here an array of objects, there `null`. Nothing stops a pve call
20
+ * importing the pbs spelling: it compiles, and the type is simply wrong about the payload.
21
+ * Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
22
+ * a vendor upgrade that adds a collision fails there rather than at runtime.
16
23
  */
17
24
  export * from './pbs/access.ts';
18
25
  export * from './pbs/access-users.ts';
@@ -13,6 +13,13 @@
13
13
  * ⚠️ 480 request parameters are typed `\`${number}\`` because the vendor calls them
14
14
  * integer or number. They were `string` before, which accepted 'banana'. A caller holding a
15
15
  * number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
16
+ *
17
+ * ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pbs.ts`, MEANING SOMETHING ELSE. Both
18
+ * products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
19
+ * exists on each side — here an array of objects, there `null`. Nothing stops a pbs call
20
+ * importing the pve spelling: it compiles, and the type is simply wrong about the payload.
21
+ * Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
22
+ * a vendor upgrade that adds a collision fails there rather than at runtime.
16
23
  */
17
24
  export * from './pve/access.ts';
18
25
  export * from './pve/access-roles.ts';
@@ -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 {
@@ -92,5 +104,5 @@ export { ProxmoxSdnVnet, ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
92
104
  export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
93
105
  export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
94
106
  export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
95
- export { type FromEnv } from './write-only.ts';
107
+ export { type FromEnv } from '../secrets/write-only.ts';
96
108
  export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';
@@ -25,7 +25,7 @@ import type {
25
25
  PbsNotificationTargetProps,
26
26
  } from './pbs-notification-target.ts';
27
27
  import { base64, headerDigest, names, wireItem } from './pbs-notification-target-wire.ts';
28
- import { type Environment, type FromEnv, resolveAll, sealMatches } from './write-only.ts';
28
+ import { type Environment, type FromEnv, resolveAll, sealMatches } from '../secrets/write-only.ts';
29
29
 
30
30
  type Props = PbsNotificationTargetProps;
31
31
  type Field = keyof Props;
@@ -32,7 +32,7 @@ import {
32
32
  targetForm,
33
33
  } from './pbs-notification-target-form.ts';
34
34
  import { targetAttributes } from './pbs-notification-target-wire.ts';
35
- import { seal } from './write-only.ts';
35
+ import { seal } from '../secrets/write-only.ts';
36
36
 
37
37
  type Props = PbsNotificationTargetProps;
38
38
  type Attributes = PbsNotificationTargetAttributes;
@@ -23,7 +23,7 @@ import type {
23
23
  PbsNotificationTargetProps,
24
24
  } from './pbs-notification-target.ts';
25
25
  import { bool, text } from './values.ts';
26
- import { seal } from './write-only.ts';
26
+ import { seal } from '../secrets/write-only.ts';
27
27
 
28
28
  export const base64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64');
29
29
 
@@ -31,7 +31,7 @@ import * as Provider from 'alchemy/Provider';
31
31
  import * as Effect from 'effect/Effect';
32
32
  import { handlers } from './pbs-notification-target-lifecycle.ts';
33
33
  import type { PveRequirements, WithPbsTarget } from './resource.ts';
34
- import type { FromEnv } from './write-only.ts';
34
+ import type { FromEnv } from '../secrets/write-only.ts';
35
35
 
36
36
  export type PbsNotificationTargetType = 'sendmail' | 'smtp' | 'webhook';
37
37
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Adoption through Alchemy's OWN Plan and Apply (openbao/fake-stack.ts): a file already at the path,
3
+ * and a stack holding no state for it. What binary-claim.ts decides, measured end to end — the plan
4
+ * and the apply must give the same answer, and `adopted` must never stand for other bytes.
5
+ *
6
+ * 🔴 EVERY REFUSAL BELOW WAS A SILENT WRITE OR LOSS BEFORE 2026-09-22 (adversarial review of PR 138,
7
+ * lens: adoption and plan parity), each measured with this harness against the code before the
8
+ * fix: other bytes under `--adopt` planned `adopted` (resolved) or `create` (directory an Output)
9
+ * and were downloaded over in place; a symlink planned `adopted` and was refused only at apply;
10
+ * a renamed declaration claimed the identical file at apply and its old name's delete removed it.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { BINARY } from './fake-release.ts';
14
+ import { DIR, PATH, THEIRS, deployHarness as harness } from './fake-deploy.ts';
15
+
16
+ describe('a file already at the path, with no state', () => {
17
+ test('OTHER bytes under --adopt, probed: the plan refuses; nothing fetched, their file kept', async () => {
18
+ const h = harness(true);
19
+ h.place(THEIRS);
20
+ await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
21
+ '--adopt takes over only the pinned binary',
22
+ );
23
+ expect(h.events).toEqual([]);
24
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
25
+ });
26
+
27
+ test('OTHER bytes under --adopt, the directory an Output: refused at apply, before the download', async () => {
28
+ const h = harness(true);
29
+ h.place(THEIRS);
30
+ await expect(h.stack.deploy(h.wired(), { adopt: true })).rejects.toThrow(
31
+ '--adopt takes over only the pinned binary',
32
+ );
33
+ expect(h.events.filter((e) => e.startsWith('GET ') || e.startsWith('write '))).toEqual([]);
34
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
35
+ });
36
+
37
+ test('a symlink under --adopt: refused by the PLAN, not first by the apply', async () => {
38
+ const h = harness(true);
39
+ h.place(new Uint8Array(), 0o755, 'symlink');
40
+ // ★ The plan-side wording; the apply's own would be "is a symlink".
41
+ await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
42
+ 'something that is not a regular file',
43
+ );
44
+ expect(h.events).toEqual([]);
45
+ });
46
+
47
+ test('the pinned bytes with a wrong mode, under --adopt: adopted, re-written from disk, never fetched', async () => {
48
+ const h = harness(true);
49
+ h.place(BINARY.vmalert, 0o775);
50
+ expect(await h.stack.deploy(h.literal, { adopt: true })).toEqual({ vmalert: 'adopted' });
51
+ expect(h.events).toEqual([`write ${PATH}`]);
52
+ expect(h.fake.files.get(PATH)?.mode).toBe(0o755);
53
+ });
54
+
55
+ test('the pinned bytes, the directory an Output, under --adopt: kept, never fetched', async () => {
56
+ const h = harness(true);
57
+ h.place(BINARY.vmalert);
58
+ const planned = await h.stack.deploy(h.wired(), { adopt: true });
59
+ expect(planned).toEqual({ dir: 'adopted', vmalert: 'create' });
60
+ expect(h.events).toEqual([]);
61
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
62
+ });
63
+
64
+ test('a renamed declaration while its directory changes: refused at apply, the binary kept', async () => {
65
+ const h = harness(false);
66
+ await h.stack.deploy(h.wired('vmalert'));
67
+ h.events.length = 0;
68
+ // The directory's mode change leaves `dir.path` an Output at plan, so nothing probes the path.
69
+ await expect(h.stack.deploy(h.wired('vmalert-rules', 0o750))).rejects.toThrow(
70
+ 'it holds the pinned binary, but this stack holds no state for it',
71
+ );
72
+ // 🔴 Before: planned {dir: update, vmalert-rules: create, vmalert: delete}; the new name took
73
+ // the identical file as its own, the old name's delete removed it, and the deploy succeeded.
74
+ expect(h.events).not.toContain(`remove ${PATH}`);
75
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
76
+ });
77
+
78
+ test('the same rename declared with renamedFrom (the remedy the refusal names): the binary kept', async () => {
79
+ const h = harness(false);
80
+ await h.stack.deploy(h.wired('vmalert'));
81
+ h.events.length = 0;
82
+ const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750, 'vmalert'));
83
+ expect(planned).toEqual({ dir: 'update', 'vmalert-rules': 'update' });
84
+ expect(h.events).toEqual([`chmod 750 ${DIR}`]);
85
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
86
+ });
87
+
88
+ test('the same rename under --adopt: the new name takes the file, the old name leaves it', async () => {
89
+ const h = harness(false);
90
+ await h.stack.deploy(h.wired('vmalert'));
91
+ h.events.length = 0;
92
+ const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750), { adopt: true });
93
+ expect(planned).toEqual({ dir: 'update', vmalert: 'delete', 'vmalert-rules': 'create' });
94
+ // 🔴 Before 2026-09-22 (release gate): the old name's orphan delete ran `remove ${PATH}` after
95
+ // the new name had claimed it, and the deploy succeeded with the binary gone.
96
+ expect(h.events).toEqual([`chmod 750 ${DIR}`]);
97
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
98
+ // The new name owns it now: the next deploy of the same declaration changes nothing.
99
+ h.events.length = 0;
100
+ expect(await h.stack.deploy(h.wired('vmalert-rules', 0o750))).toEqual({
101
+ dir: 'noop',
102
+ 'vmalert-rules': 'noop',
103
+ });
104
+ expect(h.events).toEqual([]);
105
+ });
106
+ });