@homeflare/alchemy 0.8.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Adopting an object that already matches must be read-only — measured per reconcile SHAPE, through
3
+ * Alchemy's own Plan and Apply over a fake cluster (fake-pve.ts, ../verify/fake-engine.ts).
4
+ *
5
+ * ★ ONE ROW PER WAY A FAMILY REACHES ITS WRITE, not one per family. Every family built on
6
+ * `pveHandlers` shares resource.ts's reconcile, so `Proxmox.Pool` stands for all of them; `Acl`
7
+ * wraps that reconcile; `PbsDatastore` and `SdnApply` write their own. CephPool, the one that got
8
+ * it wrong, has its own file. docs/adopt-verify.md carries the full per-family table.
9
+ * ⚠️ A FIXTURE THAT DOES NOT MATCH TURNS A ROW INTO A DRIFT TEST, and the assertion on the
10
+ * verifier's `noop` is what catches that: a wrong fixture fails loudly instead of passing for
11
+ * the wrong reason. The Pool drift row proves the harness can see a write at all.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Layer from 'effect/Layer';
15
+ import { engineOver } from '../verify/fake-engine.ts';
16
+ import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
17
+ import type { PbsTarget } from './credentials.ts';
18
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
19
+ import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
20
+ import { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
21
+ import { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
22
+
23
+ const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
24
+
25
+ type Case = {
26
+ readonly name: string;
27
+ readonly provider: () => Layer.Layer<never, never, never>;
28
+ readonly declare: () => ReturnType<typeof ProxmoxPool>;
29
+ /** GET path → the `data` PVE/PBS answers; anything unlisted answers `null`. */
30
+ readonly live: Readonly<Record<string, unknown>>;
31
+ };
32
+
33
+ const cases: readonly Case[] = [
34
+ {
35
+ declare: () => ProxmoxPool('house', { comment: 'x', poolid: 'house', target: FAKE_TARGET }),
36
+ live: { 'pools/house': { comment: 'x', members: [] } },
37
+ name: 'Proxmox.Pool (pveHandlers: every factory family)',
38
+ provider: ProxmoxPoolProvider as never,
39
+ },
40
+ {
41
+ declare: () =>
42
+ ProxmoxAcl('auditor', {
43
+ path: '/',
44
+ roleid: 'PVEAuditor',
45
+ target: FAKE_TARGET,
46
+ type: 'user',
47
+ ugid: 'u@pve',
48
+ }) as never,
49
+ live: {
50
+ 'access/acl': [
51
+ { path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
52
+ ],
53
+ },
54
+ name: 'Proxmox.Acl (wraps the factory reconcile)',
55
+ provider: ProxmoxAclProvider as never,
56
+ },
57
+ {
58
+ declare: () =>
59
+ PbsDatastore('store1', {
60
+ comment: 'c',
61
+ 'gc-schedule': 'daily',
62
+ name: 'store1',
63
+ path: '/mnt/store1',
64
+ target: PBS,
65
+ }) as never,
66
+ live: {
67
+ 'config/datastore/store1': {
68
+ comment: 'c',
69
+ 'gc-schedule': 'daily',
70
+ name: 'store1',
71
+ path: '/mnt/store1',
72
+ },
73
+ },
74
+ name: 'Pbs.Datastore (its own reconcile)',
75
+ provider: PbsDatastoreProvider as never,
76
+ },
77
+ {
78
+ declare: () => ProxmoxSdnApply('sdn', { target: FAKE_TARGET }) as never,
79
+ live: {},
80
+ name: 'Proxmox.SdnApply (its own reconcile; nothing staged)',
81
+ provider: ProxmoxSdnApplyProvider as never,
82
+ },
83
+ ];
84
+
85
+ const clusterWith = (live: Readonly<Record<string, unknown>>) =>
86
+ fakePve((call) => (call.method === 'GET' ? live[call.path] : 'UPID:fake'));
87
+
88
+ describe('adopting a matching object only reads', () => {
89
+ test.each(cases.map((c) => [c.name, c] as const))('%s', async (_, c) => {
90
+ const fake = clusterWith(c.live);
91
+ await withoutBao(async () => {
92
+ const engine = engineOver(c.provider().pipe(Layer.provideMerge(fake.layer)));
93
+ const report = await engine.verify(c.declare());
94
+ expect(report.rows).toEqual([expect.objectContaining({ diff: 'noop', ok: true })]);
95
+ expect(Object.values(await engine.deploy(c.declare()))).toEqual(['adopted']);
96
+ });
97
+ expect(fake.writes()).toEqual([]);
98
+ });
99
+
100
+ test('and the harness does see a write: a drifted Pool is PUT once', async () => {
101
+ const fake = clusterWith({ 'pools/house': { comment: 'old', members: [] } });
102
+ await withoutBao(async () => {
103
+ const engine = engineOver(ProxmoxPoolProvider().pipe(Layer.provideMerge(fake.layer)));
104
+ const declared = () =>
105
+ ProxmoxPool('house', { comment: 'x', poolid: 'house', target: FAKE_TARGET });
106
+ const report = await engine.verify(declared());
107
+ expect(report.rows[0]).toMatchObject({ changed: ['comment'], diff: 'update', ok: false });
108
+ expect(await engine.deploy(declared())).toEqual({ house: 'adopted' });
109
+ });
110
+ expect(fake.writes()).toEqual(['PUT pools/house']);
111
+ });
112
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `Proxmox.CephPool` adopted through Alchemy's own Plan and Apply, over a fake cluster.
3
+ *
4
+ * 🔴 THE BUG THIS PINS (found 2026-09-21, update-guard.ts). The family's hand-written reconcile
5
+ * PUT the declared set whenever the pool existed. Plan.ts forces reconcile after the adoption
6
+ * probe even when the diff says `noop`, so adopting a pool that already matched sent `setpool`
7
+ * and forked a worker on the live cluster. The first test fails with that PUT on the old code.
8
+ * ★ THE VERIFIER RUNS OVER THE SAME ENGINE, so the same test also shows what it reports before
9
+ * the deploy: `adopted` from the engine, `noop` from the provider.
10
+ */
11
+ import { describe, expect, test } from 'bun:test';
12
+ import * as Layer from 'effect/Layer';
13
+ import { engineOver } from '../verify/fake-engine.ts';
14
+ import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
15
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
16
+
17
+ const STATUS = 'nodes/n2/ceph/pool/cephtb4/status?verbose=1';
18
+ const OBJECT = 'nodes/n2/ceph/pool/cephtb4';
19
+
20
+ /** TB4's rbd pool as `getpool` answers it — the fields ceph-pool.ts reads. */
21
+ const livePool = (size: number): Record<string, unknown> => ({
22
+ application_list: ['rbd'],
23
+ crush_rule: 'replicated_rule',
24
+ id: 2,
25
+ min_size: 2,
26
+ name: 'cephtb4',
27
+ pg_autoscale_mode: 'on',
28
+ pg_num: 128,
29
+ size,
30
+ });
31
+
32
+ /** A cluster holding one pool; a PUT to it lands, as `setpool` would. */
33
+ const cluster = (size: number) => {
34
+ const pool = livePool(size);
35
+ return fakePve((call: PveCall) => {
36
+ if (call.method === 'GET' && call.path === STATUS) return pool;
37
+ if (call.method === 'PUT' && call.path === OBJECT) {
38
+ for (const [key, value] of Object.entries(call.form)) {
39
+ pool[key] = /^\d+$/.test(value) ? Number(value) : value;
40
+ }
41
+ return 'UPID:n2:fake:setpool';
42
+ }
43
+ return undefined;
44
+ });
45
+ };
46
+
47
+ const declared = () =>
48
+ ProxmoxCephPool('cephtb4', {
49
+ crush_rule: 'replicated_rule',
50
+ min_size: 2,
51
+ name: 'cephtb4',
52
+ node: 'n2',
53
+ pg_autoscale_mode: 'on',
54
+ size: 3,
55
+ target: FAKE_TARGET,
56
+ });
57
+
58
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
59
+ engineOver(ProxmoxCephPoolProvider().pipe(Layer.provideMerge(fake.layer)));
60
+
61
+ describe('adopting a Ceph pool', () => {
62
+ test('that already matches: the verifier says noop, and the deploy only reads', async () => {
63
+ const fake = cluster(3);
64
+ await withoutBao(async () => {
65
+ const engine = engineFor(fake);
66
+ const report = await engine.verify(declared());
67
+ expect(report.rows).toEqual([
68
+ expect.objectContaining({ diff: 'noop', ok: true, planned: 'adopted', read: 'found' }),
69
+ ]);
70
+ expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
71
+ });
72
+ expect(fake.writes()).toEqual([]);
73
+ expect(fake.calls.every((call) => call.path === STATUS)).toBe(true);
74
+ });
75
+
76
+ /**
77
+ * ★ THE RECHECK MUST NOT FAIL A FIELD THE FAMILY DOES NOT MANAGE. `pg_num` is create-time only and
78
+ * the autoscaler owns it, so a declared birth size differs from the live count and shows under
79
+ * `changed`. recheck.ts asks the diff again with the live value as recorded props; this diff
80
+ * re-reads the cluster and ignores `olds`, so it says `noop` again, and the deploy only reads.
81
+ */
82
+ test('declaring a field it does not manage: rechecked, still noop, and the deploy only reads', async () => {
83
+ const fake = cluster(3);
84
+ const birth = () =>
85
+ ProxmoxCephPool('cephtb4', {
86
+ min_size: 2,
87
+ name: 'cephtb4',
88
+ node: 'n2',
89
+ pg_num: 32,
90
+ size: 3,
91
+ target: FAKE_TARGET,
92
+ });
93
+ await withoutBao(async () => {
94
+ const engine = engineFor(fake);
95
+ const report = await engine.verify(birth());
96
+ expect(report.rows[0]).toMatchObject({
97
+ changed: ['pg_num'],
98
+ diff: 'noop',
99
+ ok: true,
100
+ recheck: 'noop',
101
+ });
102
+ expect(report.rows[0]?.why).toContain('does not manage');
103
+ expect(await engine.deploy(birth())).toEqual({ cephtb4: 'adopted' });
104
+ });
105
+ expect(fake.writes()).toEqual([]);
106
+ });
107
+
108
+ test('that drifted: the verifier names the field, and the deploy PUTs once', async () => {
109
+ const fake = cluster(2);
110
+ await withoutBao(async () => {
111
+ const engine = engineFor(fake);
112
+ const report = await engine.verify(declared());
113
+ expect(report.rows[0]).toMatchObject({ changed: ['size'], diff: 'update', ok: false });
114
+ expect(fake.writes()).toEqual([]);
115
+ expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
116
+ });
117
+ expect(fake.writes()).toEqual([`PUT ${OBJECT}`]);
118
+ expect(fake.calls.find((call) => call.method === 'PUT')?.form['size']).toBe('3');
119
+ });
120
+ });
@@ -192,9 +192,9 @@ const numeric = (value: number | undefined) => (value === undefined ? undefined
192
192
  * the way storage.ts's `mutable` serves both of its forms. `name` is the path's last segment on an
193
193
  * update and is not repeated here.
194
194
  *
195
- * ⚠️ SAFE TO RE-APPLY TO A POOL THAT ALREADY MATCHES, which reconcile relies on. MEASURED in
196
- * PVE::Ceph::Tools::set_pool (Tools.pm:293-300): it reads `osd pool get all` first and SKIPS
197
- * every setting whose value did not change, so re-sending the declared set costs one mon read.
195
+ * ⚠️ SAFE TO RE-APPLY TO A POOL THAT ALREADY MATCHES (reconcile leaned on it until update-guard.ts).
196
+ * MEASURED in PVE::Ceph::Tools::set_pool (Tools.pm:293-300): it reads `osd pool get all` first
197
+ * and SKIPS every unchanged setting, so the undrifted fields that ride along cost one mon read.
198
198
  * ⛔ BUT A SETTING THAT DID CHANGE AND CANNOT BE APPLIED KILLS THE WHOLE PUT. Tools.pm:310 dies
199
199
  * with "Could not set: <fields>" if any parameter is left unapplied, so a pool carrying Ceph's
200
200
  * `nosizechange` fails an entire update over one field. That is why the `no*` flags are reported
@@ -53,6 +53,7 @@ import {
53
53
  import { confirmAbsent, settle } from './ceph-pool-settle.ts';
54
54
  import { pve } from './client.ts';
55
55
  import { type PveRequirements, type PveSpec, pveOperations } from './resource.ts';
56
+ import { formToSend } from './update-guard.ts';
56
57
  import { bool, int, num, text } from './values.ts';
57
58
 
58
59
  export type { CephPoolAttributes, CephPoolProps };
@@ -160,9 +161,15 @@ export const ProxmoxCephPoolProvider = () =>
160
161
  createBody(news),
161
162
  );
162
163
  } else {
163
- // ⚠️ An empty form is not a write — resource.ts skips one for the same reason.
164
- const form = updateBody(news);
165
- if (Object.keys(form).length > 0) {
164
+ /**
165
+ * ⛔ ONLY WHEN `matches` IS FALSE. This PUT used to fire whenever the pool existed, so
166
+ * adopting a pool that already matched — Plan.ts forces reconcile after the probe —
167
+ * sent `setpool` and forked a worker on the live cluster. PVE skipping the unchanged
168
+ * settings (ceph-pool-form.ts) made it harmless to Ceph, not free: it is still a
169
+ * provision-lease write and a task in the cluster's list. update-guard.ts has the rest.
170
+ */
171
+ const form = formToSend(spec.matches, live, news, updateBody(news));
172
+ if (form !== undefined) {
166
173
  upid = yield* pve<string>(news.target, 'provision', 'PUT', object(news), form);
167
174
  }
168
175
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The shell's OpenBao variables, replaced for the life of one test run and put back afterwards.
3
+ *
4
+ * ⛔ WHY A TEST CANNOT LEAVE THEM ALONE. `leased` mints through credentials.ts `mint`, which reads
5
+ * `BAO_AGENT_ADDR` before `BAO_ADDR` (and the `VAULT_*` spellings), and sends `BAO_TOKEN` as a
6
+ * header. A developer shell with any of them set would send its real token to a fake, or a mint
7
+ * to a real agent. credentials.test.ts avoids it by passing an explicit environment; a test that
8
+ * drives a provider cannot, because the lease cache reads `process.env`.
9
+ * ★ ONE HELPER FOR EVERY FAKE. lxc-harness.ts and fake-pve.ts each carried their own copy; the
10
+ * first covered three names and the second every `BAO_*` / `VAULT_*`, so they now share the
11
+ * wider one. ⛔ TEST-ONLY. No provider imports this file.
12
+ */
13
+ const BAO_VARIABLES = /^(BAO|VAULT)_/;
14
+
15
+ /** Run `body` with every BAO_* / VAULT_* unset except `BAO_ADDR=address`, then restore them. */
16
+ export const withFakeBao = async <A>(address: string, body: () => Promise<A>): Promise<A> => {
17
+ const saved = Object.entries(process.env).filter(([name]) => BAO_VARIABLES.test(name));
18
+ for (const [name] of saved) delete process.env[name];
19
+ process.env['BAO_ADDR'] = address;
20
+ try {
21
+ return await body();
22
+ } finally {
23
+ for (const name of Object.keys(process.env)) {
24
+ if (BAO_VARIABLES.test(name)) delete process.env[name];
25
+ }
26
+ for (const [name, value] of saved) process.env[name] = value;
27
+ }
28
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * A fake PVE cluster for `Proxmox.Lxc`: containers in memory, tasks that finish at once, and the
3
+ * OpenBao creds endpoint the mint reads — one Bun server, reached through `FetchHttpClient.Fetch`.
4
+ *
5
+ * ⛔ TEST-ONLY, AND NEVER A REAL HOST. No provider imports this file and it is not on the barrel.
6
+ * Hostnames are `.test` (RFC 2606) and are remapped to the loopback server; any other host is
7
+ * refused with ENOTFOUND rather than resolved (client.test.ts has the incident behind that).
8
+ * ★ IT ANSWERS THE WAY PVE 9 DOES WHERE THE PROVIDER DEPENDS ON IT, and each shape is a measured
9
+ * one: a missing config is HTTP 500 with `message` in the JSON body (pve-http-server
10
+ * `Formatter/Standard.pm`); a PUT with a stale `digest` is refused; a create allocates
11
+ * `<storage>:subvol-<vmid>-disk-<n>,size=<n>G` and generates `hwaddr` and `type=veth`, as a live
12
+ * guest's own config shows; tags come back sorted and lower-cased (`get_unique_tags`).
13
+ * ⚠️ EVERY MAC HERE IS FROM THE RFC 7042 DOCUMENTATION BLOCK (`00:00:5E:00:53:xx`), as every
14
+ * address is TEST-NET: PVE's own prefix is `BC:24:11`, but no value in this package may be one a
15
+ * real NIC could carry.
16
+ */
17
+ import { pairs } from './lxc-wire.ts';
18
+
19
+ export type Seen = {
20
+ readonly method: string;
21
+ readonly path: string;
22
+ readonly form: URLSearchParams;
23
+ };
24
+
25
+ export type FakePve = {
26
+ /** Live configs, keyed `node/vmid`. Mutate freely between deploys to simulate hand edits. */
27
+ readonly guests: Map<string, Record<string, unknown>>;
28
+ /** Extra `GET /cluster/resources` rows — a QEMU VM, or a guest on another node. */
29
+ readonly others: { vmid: number | string; node: string; type: string }[];
30
+ /** Forced answers for `GET …/config`, keyed `node/vmid`: an HTTP status and a JSON body. */
31
+ readonly configErrors: Map<string, { status: number; body: unknown }>;
32
+ /**
33
+ * Guests that read as ABSENT for the next N cluster listings (their config GET 500s meanwhile),
34
+ * keyed `node/vmid` — a guest that appears between a plan and its deploy.
35
+ */
36
+ readonly vanish: Map<string, number>;
37
+ /** Config keys a `PUT …/config` accepts with 200 and then does not store. */
38
+ readonly ignore: Set<string>;
39
+ /** How every task ends, and whether a task-starting call answers with no UPID at all. */
40
+ readonly task: { exit: string; noUpid: boolean };
41
+ readonly seen: Seen[];
42
+ readonly fetch: typeof fetch;
43
+ readonly stop: () => void;
44
+ };
45
+
46
+ /** The member hostname a test target names, and the OpenBao address the mint is pointed at. */
47
+ export const FAKE_MEMBER = 'pve-a.test';
48
+ export const FAKE_BAO = 'http://bao.test:8200';
49
+
50
+ const json = (body: unknown, status = 200) => Response.json(body, { status });
51
+
52
+ let digestCounter = 0;
53
+ const nextDigest = () => {
54
+ digestCounter += 1;
55
+ return `digest-${String(digestCounter)}`;
56
+ };
57
+
58
+ /** What PVE's create does to the declared form: allocate volumes, fill NIC defaults, sort tags. */
59
+ const created = (vmid: string, form: URLSearchParams): Record<string, unknown> => {
60
+ const config: Record<string, unknown> = { arch: 'amd64', ostype: 'debian', unprivileged: 1 };
61
+ let disk = 0;
62
+ for (const [key, value] of form) {
63
+ if (['ostemplate', 'start', 'vmid'].includes(key)) continue;
64
+ if (key === 'rootfs' || /^mp\d+$/.test(key)) {
65
+ const [first, ...rest] = value.split(',');
66
+ const fresh = /^([^:]+):(\d+)$/.exec(first ?? '');
67
+ config[key] =
68
+ fresh === null
69
+ ? value
70
+ : [
71
+ `${fresh[1] ?? ''}:subvol-${vmid}-disk-${String(disk++)}`,
72
+ ...rest,
73
+ `size=${fresh[2] ?? '0'}G`,
74
+ ].join(',');
75
+ } else if (/^net\d+$/.test(key)) {
76
+ const map = new Map(pairs(value));
77
+ if (!map.has('hwaddr'))
78
+ map.set('hwaddr', `00:00:5E:00:53:${vmid.slice(-2).padStart(2, '0')}`);
79
+ if (!map.has('type')) map.set('type', 'veth');
80
+ config[key] = [...map].map(([k, v]) => `${k}=${v}`).join(',');
81
+ } else if (key === 'tags') {
82
+ config[key] = [...new Set(value.toLowerCase().split(/[;,\s]+/))].sort().join(';');
83
+ } else config[key] = /^\d+$/.test(value) ? Number(value) : value;
84
+ }
85
+ return config;
86
+ };
87
+
88
+ export const fakePve = (): FakePve => {
89
+ const guests = new Map<string, Record<string, unknown>>();
90
+ const others: { vmid: number | string; node: string; type: string }[] = [];
91
+ const configErrors = new Map<string, { status: number; body: unknown }>();
92
+ const vanish = new Map<string, number>();
93
+ const ignore = new Set<string>();
94
+ const task = { exit: 'OK', noUpid: false };
95
+ const seen: Seen[] = [];
96
+ let upids = 0;
97
+ const upid = (node: string, kind: string, vmid: string) => {
98
+ upids += 1;
99
+ if (task.noUpid) return null;
100
+ return `UPID:${node}:${String(upids).padStart(8, '0')}:0:0:${kind}:${vmid}:hf-test@pve!fake:`;
101
+ };
102
+
103
+ const handle = async (request: Request): Promise<Response> => {
104
+ const url = new URL(request.url);
105
+ // ⚠️ QUERY AND BODY BOTH: a DELETE carries its parameters in the query, and a `force=1` there
106
+ // must be visible to the test that says there is none.
107
+ const form = new URLSearchParams(url.search);
108
+ if (request.method !== 'GET')
109
+ for (const pair of new URLSearchParams(await request.text())) form.append(...pair);
110
+ seen.push({ form, method: request.method, path: url.pathname });
111
+ if (url.pathname.startsWith('/v1/') && url.pathname.includes('/creds/')) {
112
+ return json({
113
+ data: { secret: 'not-a-secret', token_id: 'hf-test@pve!fake' },
114
+ lease_duration: 300,
115
+ });
116
+ }
117
+ const path = url.pathname.replace(/^\/api2\/json\//, '');
118
+ if (path === 'cluster/resources') {
119
+ const rows = [...guests.keys()]
120
+ .filter((key) => (vanish.get(key) ?? 0) <= 0)
121
+ .map((key) => {
122
+ const [node, vmid] = key.split('/');
123
+ return { node, type: 'lxc', vmid: Number(vmid) };
124
+ });
125
+ for (const [key, left] of vanish) vanish.set(key, left - 1);
126
+ return json({ data: [...rows, ...others] });
127
+ }
128
+ if (/^nodes\/[^/]+\/tasks\/.+\/status$/.test(path)) {
129
+ return json({ data: { exitstatus: task.exit, status: 'stopped' } });
130
+ }
131
+ const create = /^nodes\/([^/]+)\/lxc$/.exec(path);
132
+ if (create !== null && request.method === 'POST') {
133
+ const node = create[1] ?? '';
134
+ const vmid = form.get('vmid') ?? '';
135
+ guests.set(`${node}/${vmid}`, { ...created(vmid, form), digest: nextDigest() });
136
+ return json({ data: upid(node, 'vzcreate', vmid) });
137
+ }
138
+ const guest = /^nodes\/([^/]+)\/lxc\/(\d+)(\/config|\/resize)?$/.exec(path);
139
+ if (guest === null) return json({ data: null, message: `no such path ${path}\n` }, 501);
140
+ const [, node = '', vmid = '', tail] = guest;
141
+ const forced = configErrors.get(`${node}/${vmid}`);
142
+ if (forced !== undefined && tail === '/config' && request.method === 'GET') {
143
+ return json(forced.body, forced.status);
144
+ }
145
+ const config =
146
+ (vanish.get(`${node}/${vmid}`) ?? 0) > 0 ? undefined : guests.get(`${node}/${vmid}`);
147
+ if (config === undefined) {
148
+ const message = `Configuration file 'nodes/${node}/lxc/${vmid}.conf' does not exist\n`;
149
+ return json({ data: null, message }, 500);
150
+ }
151
+ if (tail === '/config' && request.method === 'GET') return json({ data: config });
152
+ if (tail === '/config' && request.method === 'PUT') {
153
+ const digest = form.get('digest');
154
+ if (digest !== null && digest !== config['digest']) {
155
+ return json({ data: null, message: 'detected modified configuration\n' }, 500);
156
+ }
157
+ for (const [key, value] of form)
158
+ if (key !== 'digest' && key !== 'delete' && !ignore.has(key)) config[key] = value;
159
+ for (const key of (form.get('delete') ?? '').split(',')) if (key !== '') delete config[key];
160
+ config['digest'] = nextDigest();
161
+ return json({ data: null });
162
+ }
163
+ if (tail === '/resize' && request.method === 'PUT') {
164
+ const disk = form.get('disk') ?? '';
165
+ const current = String(config[disk] ?? '');
166
+ config[disk] = current.replace(/size=[^,]+/, `size=${form.get('size') ?? ''}`);
167
+ config['digest'] = nextDigest();
168
+ return json({ data: upid(node, 'resize', vmid) });
169
+ }
170
+ if (tail === undefined && request.method === 'DELETE') {
171
+ guests.delete(`${node}/${vmid}`);
172
+ return json({ data: upid(node, 'vzdestroy', vmid) });
173
+ }
174
+ return json({ data: null, message: 'unhandled\n' }, 501);
175
+ };
176
+
177
+ const server = Bun.serve({ fetch: handle, port: 0 });
178
+ const port = String(server.port);
179
+ const remapped = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
180
+ const url = new URL(input instanceof Request ? input.url : String(input));
181
+ if (url.hostname !== FAKE_MEMBER && url.hostname !== new URL(FAKE_BAO).hostname) {
182
+ return Promise.reject(
183
+ Object.assign(new Error(`getaddrinfo ENOTFOUND ${url.hostname}`), {
184
+ code: 'ENOTFOUND',
185
+ }),
186
+ );
187
+ }
188
+ url.protocol = 'http:';
189
+ url.hostname = '127.0.0.1';
190
+ url.port = port;
191
+ return globalThis.fetch(url, init);
192
+ };
193
+ return {
194
+ configErrors,
195
+ fetch: Object.assign(remapped, { preconnect: globalThis.fetch.preconnect }),
196
+ guests,
197
+ ignore,
198
+ others,
199
+ seen,
200
+ stop: () => server.stop(true),
201
+ task,
202
+ vanish,
203
+ };
204
+ };
205
+
206
+ /** Every call that changed something, as `METHOD path`, in order — OpenBao mints excluded. */
207
+ export const writesOf = (fake: FakePve) =>
208
+ fake.seen
209
+ .filter((call) => call.method !== 'GET')
210
+ .map((call) => `${call.method} ${call.path.replace(/^\/api2\/json\//, '')}`);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A PVE cluster and the OpenBao that mints for it, both answered in-process by one stub `fetch` —
3
+ * so a family's REAL provider can run through Alchemy's engine with every call recorded.
4
+ *
5
+ * ★ A STUB `fetch`, NOT A SERVER. client.test.ts remaps hosts onto Bun.serve to test failover;
6
+ * here the question is only which calls a lifecycle makes, and a stub answers every URL without
7
+ * a socket, so nothing can reach a real host whatever the environment says.
8
+ * ⛔ AND THE ENVIRONMENT IS EMPTIED ANYWAY (`withoutBao`, fake-bao-env.ts), so a token in the
9
+ * shell running the tests is never read.
10
+ * ⚠️ `lease_duration: 0` KEEPS THE LEASE CACHE COLD (lease-cache.ts `timeToLive`), so no fake
11
+ * credential outlives the test that minted it.
12
+ * ⛔ TEST-ONLY. No provider imports this file.
13
+ */
14
+ import * as Layer from 'effect/Layer';
15
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
16
+ import type { PveTarget } from './credentials.ts';
17
+ import { withFakeBao } from './fake-bao-env.ts';
18
+ import { resetLastGoodForTest } from './members.ts';
19
+
20
+ export const FAKE_TARGET: PveTarget = {
21
+ members: ['pve.test'],
22
+ mount: 'proxmox-test',
23
+ scheme: 'pve',
24
+ };
25
+
26
+ export interface PveCall {
27
+ readonly method: string;
28
+ /** Relative to `/api2/json/`, query included — the string a family passes to `pve()`. */
29
+ readonly path: string;
30
+ readonly form: Readonly<Record<string, string>>;
31
+ }
32
+
33
+ /** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
34
+ export type PveAnswer = (call: PveCall) => unknown;
35
+
36
+ export interface FakePve {
37
+ readonly calls: PveCall[];
38
+ /** Every call that is not a GET, as `METHOD path`. */
39
+ readonly writes: () => string[];
40
+ /** Provide over a provider layer: `Provider().pipe(Layer.provideMerge(fake.layer))`. */
41
+ readonly layer: ReturnType<typeof transport>;
42
+ }
43
+
44
+ /** Effect's own fetch client over a stub — client.test.ts wires failover the same way. */
45
+ const transport = (stub: typeof fetch) =>
46
+ FetchHttpClient.layer.pipe(Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, stub)));
47
+
48
+ export const fakePve = (answer: PveAnswer): FakePve => {
49
+ const calls: PveCall[] = [];
50
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
51
+ const request =
52
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
53
+ const url = new URL(request.url);
54
+ if (url.pathname.includes('/creds/')) {
55
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
56
+ return Response.json({ data, lease_duration: 0 });
57
+ }
58
+ const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
59
+ const form = Object.fromEntries(new URLSearchParams(await request.text()));
60
+ const call = { form, method: request.method, path };
61
+ calls.push(call);
62
+ return Response.json({ data: answer(call) ?? null });
63
+ };
64
+ // ⚠️ `typeof fetch` carries `preconnect` on bun's lib — client.test.ts has the note.
65
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
66
+ return {
67
+ calls,
68
+ layer: transport(fetchStub as typeof fetch),
69
+ writes: () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`),
70
+ };
71
+ };
72
+
73
+ /** Run `body` against the stub's OpenBao (fake-bao-env.ts), then forget the member failover state. */
74
+ export const withoutBao = <A>(body: () => Promise<A>): Promise<A> =>
75
+ withFakeBao('http://bao.invalid', body).finally(resetLastGoodForTest);
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Public barrel: control-plane Resource constructors, not guests or NIC apply.
2
+ * Public barrel: control-plane Resource constructors and `ProxmoxLxc`, not QEMU or NIC apply.
3
3
  *
4
- * ⛔ Lxc/Vm stay Provider-only. A Resource export would let a stack declare
5
- * guests before storage/SDN exist. NodeNetwork/NetworkApply stay off the
6
- * barrel for the same reason — they stage interfaces.new.
4
+ * ⛔ Vm stays Provider-only. NodeNetwork/NetworkApply stay off the barrel — they stage
5
+ * interfaces.new. ★ Lxc is a Resource since 2026-09-21: adopt-first, refuse-not-replace,
6
+ * retain on destroy (lxc.ts).
7
7
  */
8
8
  import { expect, test } from 'bun:test';
9
9
  import { PbsDatastore, ProxmoxStorage } from './index.ts';
@@ -20,6 +20,7 @@ const resourceExports = [
20
20
  'ProxmoxGroup',
21
21
  'ProxmoxHaResource',
22
22
  'ProxmoxHaRule',
23
+ 'ProxmoxLxc',
23
24
  'ProxmoxMetricServer',
24
25
  'ProxmoxNotificationTarget',
25
26
  'ProxmoxRole',
@@ -39,8 +40,7 @@ test('control-plane Resource constructors are on the public barrel', () => {
39
40
  }
40
41
  });
41
42
 
42
- test('guests and NIC apply stay Provider-only', () => {
43
- expect(src).not.toMatch(/export \{ ProxmoxLxc[, }]/);
43
+ test('QEMU and NIC apply stay Provider-only', () => {
44
44
  expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
45
45
  expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
46
46
  expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
@@ -6,8 +6,12 @@
6
6
  * provider needs but a consumer should not depend on. An `export *` here would publish
7
7
  * every helper as API and make the next refactor a breaking change.
8
8
  * ★ Control-plane Resource constructors (Storage, SDN, access, HA, backup,
9
- * metrics, PBS) sit next to their Providers. Guests and NIC apply stay
9
+ * metrics, PBS) sit next to their Providers. NIC apply and QEMU stay
10
10
  * Provider-only so a stack cannot declare them by accident.
11
+ * ★ `ProxmoxLxc` JOINED THEM 2026-09-21, AND IT WAS KEPT OFF UNTIL IT COULD NOT HURT A GUEST.
12
+ * The provider-only version diffed four fields and would have planned a create over any read
13
+ * failure. The Resource now adopts from the live config, refuses every change PVE cannot make
14
+ * in place, and retains on destroy (lxc.ts).
11
15
  * ★ Anything else unlisted is still reachable by path if you genuinely need
12
16
  * it — that is a deliberate, visible act rather than an accident of barrelling.
13
17
  */
@@ -23,7 +27,7 @@ export { ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
23
27
  export { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
24
28
  export { ProxmoxHaResource, ProxmoxHaResourceProvider } from './ha-resource.ts';
25
29
  export { ProxmoxHaRule, ProxmoxHaRuleProvider } from './ha-rule.ts';
26
- export { ProxmoxLxcProvider } from './lxc.ts';
30
+ export { type LxcAttributes, type LxcProps, ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
27
31
  export { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
28
32
  export { ProxmoxNetworkApplyProvider } from './network-apply.ts';
29
33
  export { ProxmoxNodeNetworkProvider } from './node-network.ts';