@homeflare/alchemy 0.10.1 → 0.11.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 (42) hide show
  1. package/dist/caddy/index.js.map +2 -2
  2. package/dist/launchd/index.js.map +2 -2
  3. package/dist/openbao/index.js +10 -8
  4. package/dist/openbao/index.js.map +4 -4
  5. package/dist/ownership/adopting.d.ts +34 -0
  6. package/dist/ownership/adopting.d.ts.map +1 -0
  7. package/dist/ownership/probe.d.ts +15 -5
  8. package/dist/ownership/probe.d.ts.map +1 -1
  9. package/dist/ownership/rows.d.ts +12 -0
  10. package/dist/ownership/rows.d.ts.map +1 -1
  11. package/dist/ownership/store-fixture.d.ts +10 -0
  12. package/dist/ownership/store-fixture.d.ts.map +1 -0
  13. package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
  14. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  15. package/dist/proxmox/index.js +147 -113
  16. package/dist/proxmox/index.js.map +8 -6
  17. package/dist/proxmox/lxc-adoption.d.ts +32 -0
  18. package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
  19. package/dist/proxmox/lxc-harness.d.ts +12 -0
  20. package/dist/proxmox/lxc-harness.d.ts.map +1 -1
  21. package/dist/proxmox/lxc.d.ts +5 -5
  22. package/dist/proxmox/lxc.d.ts.map +1 -1
  23. package/docs/adopted-deploys.md +4 -2
  24. package/docs/ownership.md +1 -1
  25. package/docs/proxmox-lxc-adopt.md +83 -0
  26. package/docs/proxmox-lxc.md +7 -45
  27. package/docs/proxmox.md +2 -0
  28. package/package.json +1 -1
  29. package/src/ownership/adopting.test.ts +63 -0
  30. package/src/ownership/adopting.ts +46 -0
  31. package/src/ownership/ownership.test.ts +1 -34
  32. package/src/ownership/probe.ts +27 -14
  33. package/src/ownership/rows.ts +8 -2
  34. package/src/ownership/store-fixture.ts +50 -0
  35. package/src/proxmox/fake-pve-lxc.ts +13 -1
  36. package/src/proxmox/lxc-adopt.test.ts +13 -20
  37. package/src/proxmox/lxc-adoption.ts +50 -0
  38. package/src/proxmox/lxc-guards.test.ts +6 -13
  39. package/src/proxmox/lxc-harness.ts +25 -2
  40. package/src/proxmox/lxc-ownership.test.ts +19 -14
  41. package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
  42. package/src/proxmox/lxc.ts +16 -12
@@ -34,6 +34,11 @@ export type FakePve = {
34
34
  * keyed `node/vmid` — a guest that appears between a plan and its deploy.
35
35
  */
36
36
  readonly vanish: Map<string, number>;
37
+ /**
38
+ * Hand edits that land mid-deploy, keyed `node/vmid`: once that guest's config has been read
39
+ * `after` more times, `set` is merged into it — a change made between a plan and its apply.
40
+ */
41
+ readonly edits: Map<string, { after: number; readonly set: Record<string, unknown> }>;
37
42
  /** Config keys a `PUT …/config` accepts with 200 and then does not store. */
38
43
  readonly ignore: Set<string>;
39
44
  /** How every task ends, and whether a task-starting call answers with no UPID at all. */
@@ -90,6 +95,7 @@ export const fakePve = (): FakePve => {
90
95
  const others: { vmid: number | string; node: string; type: string }[] = [];
91
96
  const configErrors = new Map<string, { status: number; body: unknown }>();
92
97
  const vanish = new Map<string, number>();
98
+ const edits: FakePve['edits'] = new Map();
93
99
  const ignore = new Set<string>();
94
100
  const task = { exit: 'OK', noUpid: false };
95
101
  const seen: Seen[] = [];
@@ -148,7 +154,12 @@ export const fakePve = (): FakePve => {
148
154
  const message = `Configuration file 'nodes/${node}/lxc/${vmid}.conf' does not exist\n`;
149
155
  return json({ data: null, message }, 500);
150
156
  }
151
- if (tail === '/config' && request.method === 'GET') return json({ data: config });
157
+ if (tail === '/config' && request.method === 'GET') {
158
+ const answer = json({ data: config });
159
+ const edit = edits.get(`${node}/${vmid}`);
160
+ if (edit !== undefined && --edit.after === 0) Object.assign(config, edit.set);
161
+ return answer;
162
+ }
152
163
  if (tail === '/config' && request.method === 'PUT') {
153
164
  const digest = form.get('digest');
154
165
  if (digest !== null && digest !== config['digest']) {
@@ -192,6 +203,7 @@ export const fakePve = (): FakePve => {
192
203
  };
193
204
  return {
194
205
  configErrors,
206
+ edits,
195
207
  fetch: Object.assign(remapped, { preconnect: globalThis.fetch.preconnect }),
196
208
  guests,
197
209
  ignore,
@@ -4,28 +4,20 @@
4
4
  *
5
5
  * ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
6
6
  * its deploy writes NOTHING, and the next plan is `noop`; the create spellings (`storage:size`,
7
- * a NIC without its MAC) adopt as cleanly; drift is named at plan and written as one PUT that
8
- * keeps the live MAC and carries the digest; and every change PVE cannot make in place fails the
9
- * plan with nothing written. Deleting both empty-change guards (reconcile's in lxc.ts and
10
- * `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in lxc-wire.ts, fails a test here —
11
- * each MEASURED by making that edit and running this file, 2026-09-21.
7
+ * a NIC without its MAC) adopt as cleanly; after that adoption, drift is named at plan and
8
+ * written as one PUT that keeps the live MAC and carries the digest; and every change PVE cannot
9
+ * make in place fails the plan with nothing written. Deleting both empty-change guards
10
+ * (reconcile's in lxc.ts and `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
11
+ * lxc-wire.ts, fails a test here — each MEASURED by making that edit and running this file,
12
+ * 2026-09-21. An adoption that WOULD write fails instead: lxc-strict-adopt.test.ts.
12
13
  */
13
14
  import { afterEach, describe, expect, test } from 'bun:test';
14
15
  import { adopt } from 'alchemy/AdoptPolicy';
15
16
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
16
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
17
+ import { NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
17
18
  import { ProxmoxLxc } from './lxc.ts';
18
19
  import type { LxcProps } from './lxc-props.ts';
19
20
 
20
- const NODE = 'pve1';
21
- const VMID = 100;
22
-
23
- /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
24
- const pasted = (): LxcProps => {
25
- const { lxc: _raw, ...config } = LIVE;
26
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID };
27
- };
28
-
29
21
  const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
30
22
 
31
23
  let fake: FakePve | undefined;
@@ -84,17 +76,17 @@ describe('adopting a live container', () => {
84
76
  });
85
77
  });
86
78
 
87
- describe('drift at adoption is named at plan and written once', () => {
79
+ describe('drift after the adoption is named at plan and written once', () => {
88
80
  test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
89
81
  const pve = live();
90
- const stack = lxcEngine(pve);
82
+ const stack = await adoptedAsIs(pve);
91
83
  const props: LxcProps = {
92
84
  ...pasted(),
93
85
  memory: 4096,
94
86
  net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
95
87
  };
96
88
  const planned = await stack.plan(ct(props));
97
- expect(planned.actions).toEqual({ ct: 'adopted' });
89
+ expect(planned.actions).toEqual({ ct: 'update' });
98
90
  expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
99
91
  expect((await stack.deploy(ct(props))).failure).toBe('');
100
92
  expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
@@ -107,7 +99,7 @@ describe('drift at adoption is named at plan and written once', () => {
107
99
 
108
100
  test('a larger rootfs is a resize after the config, never a new volume', async () => {
109
101
  const pve = live();
110
- const stack = lxcEngine(pve);
102
+ const stack = await adoptedAsIs(pve);
111
103
  const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-100-disk-0,size=64G' };
112
104
  expect((await stack.deploy(ct(props))).failure).toBe('');
113
105
  expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
@@ -125,7 +117,8 @@ describe('changes PVE cannot make in place fail the plan and write nothing', ()
125
117
  ['detaching a mount point', { mp0: '' }, /unusedN/],
126
118
  ])('%s', async (_, change, reason) => {
127
119
  const pve = live();
128
- const run = await lxcEngine(pve).deploy(ct({ ...pasted(), ...change } as LxcProps));
120
+ const stack = await adoptedAsIs(pve);
121
+ const run = await stack.deploy(ct({ ...pasted(), ...change } as LxcProps));
129
122
  expect(run.failure).toMatch(reason);
130
123
  expect(writesOf(pve)).toEqual([]);
131
124
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The strict adoption gate for `Proxmox.Lxc`: AN ADOPTION NEVER CHANGES A GUEST (decided
3
+ * 2026-09-21). A guest this stack adopts — no recorded instance, or one the plan only kept because
4
+ * `--adopt` said so (ownership/adopting.ts) — is taken over exactly as it runs, or not at all.
5
+ *
6
+ * ⛔ ANY DIFFERENCE FAILS, AT PLAN AND AGAIN AT APPLY. Before this, a cold adoption planned `adopted`
7
+ * whatever it would write (alchemy beta.79 prints no property diff for it) and only LOGGED the
8
+ * keys; `deploy --adopt --yes` then wrote them. A `net0` pasted without the live `tag=` moved the
9
+ * guest off its VLAN on the deploy that was meant to change nothing. Now the plan fails naming
10
+ * the keys, and reconcile re-asks against a fresh read, so a hand edit between plan and deploy
11
+ * is refused too rather than written back.
12
+ * ⛔ KEYS ONLY, NEVER VALUES. The refusal names what differs and nothing it differs by: a live
13
+ * config can carry anything (`description` is where people paste credentials), and plan output
14
+ * lands in CI logs.
15
+ * ★ ASKED BEFORE `judge`'s OWN REFUSALS. Those name a declared value (the `pct set` to run), and
16
+ * for an adoption the fix is the other direction: declare what is live.
17
+ * ★ THE STORE IS READ ONLY WHEN SOMETHING DIFFERS. A clean adoption — the only kind that proceeds
18
+ * — costs nothing here.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import type { Owner } from '../ownership/adopt.ts';
22
+ import { adopting } from '../ownership/adopting.ts';
23
+ import { type LxcChange, judge } from './lxc-judge.ts';
24
+ import { LxcRefusedError } from './lxc-lifecycle.ts';
25
+ import type { LxcProps } from './lxc-props.ts';
26
+
27
+ /** The refusal: keys, where, and what to do — never a value. */
28
+ export const adoptionRefusal = (props: LxcProps, drift: readonly string[]): string =>
29
+ `CT ${String(props.vmid)} on ${props.node}: adopting it would change ${drift.join(', ')}. An ` +
30
+ 'adoption never changes a guest, so nothing is written. Declare what is live for these keys ' +
31
+ '(the plan names keys, never values) and plan again; change them after the adoption, as an ' +
32
+ 'update.';
33
+
34
+ /**
35
+ * Fails with `adoptionRefusal` when `change` would write to a guest `owner`'s call adopts.
36
+ * `settled` for a recovered create is the same judge, of the recording row's props against `live`.
37
+ */
38
+ export const refuseAdoptedDrift = (
39
+ owner: Owner,
40
+ props: LxcProps,
41
+ live: Record<string, unknown>,
42
+ change: LxcChange,
43
+ ): Effect.Effect<void, LxcRefusedError> =>
44
+ Effect.gen(function* () {
45
+ if (change.drift.length === 0) return;
46
+ const settled = (recorded: unknown) =>
47
+ Effect.sync(() => judge(recorded as LxcProps, live).drift.length === 0);
48
+ if (!(yield* adopting(owner, settled))) return;
49
+ return yield* Effect.fail(new LxcRefusedError(adoptionRefusal(props, change.drift)));
50
+ });
@@ -11,19 +11,12 @@
11
11
  import { afterEach, describe, expect, test } from 'bun:test';
12
12
  import { adopt } from 'alchemy/AdoptPolicy';
13
13
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
14
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
14
+ import { KEY, NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
15
15
  import { ProxmoxLxc } from './lxc.ts';
16
16
  import type { LxcProps } from './lxc-props.ts';
17
17
 
18
- const NODE = 'pve1';
19
- const VMID = 100;
20
- const KEY = `${NODE}/${String(VMID)}`;
21
18
  const TEMPLATE = 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst';
22
-
23
- const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
24
- const { lxc: _raw, ...config } = LIVE;
25
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
26
- };
19
+ const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
27
20
 
28
21
  let fake: FakePve | undefined;
29
22
  afterEach(() => {
@@ -113,10 +106,9 @@ describe('a write is claimed only when PVE says it finished', () => {
113
106
  test('a config write PVE accepted but did not store fails the read-back', async () => {
114
107
  const pve = cluster();
115
108
  seed(pve, NODE, VMID);
109
+ const stack = await adoptedAsIs(pve);
116
110
  pve.ignore.add('memory');
117
- const run = await lxcEngine(pve).deploy(
118
- ProxmoxLxc('ct', pasted({ memory: 4096 })).pipe(adopt(true)),
119
- );
111
+ const run = await stack.deploy(ct(pasted({ memory: 4096 })));
120
112
  expect(run.failure).toContain('memory still differ');
121
113
  });
122
114
  });
@@ -137,8 +129,9 @@ describe('identity and volumes', () => {
137
129
  test('options and growth on one volume: the PUT keeps the live size, the resize follows', async () => {
138
130
  const pve = cluster();
139
131
  seed(pve, NODE, VMID);
132
+ const stack = await adoptedAsIs(pve);
140
133
  const mp0 = 'tank:subvol-100-disk-0,mp=/data,backup=1,size=300G';
141
- const run = await lxcEngine(pve).deploy(ProxmoxLxc('ct', pasted({ mp0 })).pipe(adopt(true)));
134
+ const run = await stack.deploy(ct(pasted({ mp0 })));
142
135
  expect(run.failure).toBe('');
143
136
  const path = `nodes/${NODE}/lxc/${String(VMID)}`;
144
137
  expect(writesOf(pve)).toEqual([`PUT ${path}/config`, `PUT ${path}/resize`]);
@@ -13,7 +13,7 @@
13
13
  * ★ ONE ARTIFACT STORE ACROSS A PLAN AND ITS APPLY, and `--adopt` as the AdoptPolicy service, as
14
14
  * openbao/fake-stack.ts provides them: the ownership rule reads both (src/ownership/).
15
15
  */
16
- import { AdoptPolicy } from 'alchemy/AdoptPolicy';
16
+ import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
17
17
  import { apply } from 'alchemy/Apply';
18
18
  import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
19
  import * as Plan from 'alchemy/Plan';
@@ -29,7 +29,8 @@ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
29
29
  import type { PveTarget } from './credentials.ts';
30
30
  import { withFakeBao } from './fake-bao-env.ts';
31
31
  import { FAKE_BAO, FAKE_MEMBER, type FakePve } from './fake-pve-lxc.ts';
32
- import { ProxmoxLxcProvider } from './lxc.ts';
32
+ import type { LxcProps } from './lxc-props.ts';
33
+ import { ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
33
34
 
34
35
  export const TARGET: PveTarget = {
35
36
  members: [FAKE_MEMBER],
@@ -68,6 +69,17 @@ export const LIVE: Readonly<Record<string, unknown>> = {
68
69
  unprivileged: 1,
69
70
  };
70
71
 
72
+ /** Where `seed` puts the production-shaped guest by default, and its `node/vmid` key. */
73
+ export const NODE = 'pve1';
74
+ export const VMID = 100;
75
+ export const KEY = `${NODE}/${String(VMID)}`;
76
+
77
+ /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
78
+ export const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
79
+ const { lxc: _raw, ...config } = LIVE;
80
+ return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
81
+ };
82
+
71
83
  /** Put `config` live on the fake as `node/vmid`, with a digest as PVE would add. */
72
84
  export const seed = (fake: FakePve, node: string, vmid: number, config = LIVE) => {
73
85
  fake.guests.set(`${node}/${String(vmid)}`, { ...config, digest: 'digest-live' });
@@ -148,3 +160,14 @@ export const lxcEngine = (fake: FakePve) => {
148
160
  status: (fqn: string) => rows['lxc']?.['test']?.[fqn]?.status,
149
161
  };
150
162
  };
163
+
164
+ /**
165
+ * The engine, with the guest `seed` put at NODE/VMID adopted exactly as it runs — the only adoption
166
+ * that proceeds (lxc-adoption.ts) — so a test can go on to change it, as an ordinary update.
167
+ */
168
+ export const adoptedAsIs = async (pve: FakePve): Promise<ReturnType<typeof lxcEngine>> => {
169
+ const stack = lxcEngine(pve);
170
+ const run = await stack.deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)));
171
+ if (run.failure !== '') throw new Error(`the clean adoption failed: ${run.failure}`);
172
+ return stack;
173
+ };
@@ -7,7 +7,8 @@
7
7
  * planned a create refuses a guest it then finds, and forgets its `creating` row; under
8
8
  * `--adopt` it records a matching one with no write and still refuses one that differs; and a
9
9
  * create interrupted after its POST resumes WITHOUT `--adopt` when the guest is what it wrote,
10
- * and asks for `--adopt` when someone changed it meanwhile.
10
+ * and asks for `--adopt` when someone changed it meanwhile — which then adopts it only as it
11
+ * now runs, never writing the declaration back over the change (lxc-adoption.ts).
11
12
  * ★ THE INTERRUPTION IS A CREATE TASK THAT ENDS IN AN ERROR AFTER THE FAKE BUILT THE GUEST — to the
12
13
  * engine the same as a deploy killed while it waited on the task: a live guest, and a
13
14
  * `creating` row with no attributes.
@@ -15,20 +16,12 @@
15
16
  import { afterEach, describe, expect, test } from 'bun:test';
16
17
  import { adopt } from 'alchemy/AdoptPolicy';
17
18
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
18
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
19
+ import { KEY, NODE, TARGET, VMID, lxcEngine, pasted, seed } from './lxc-harness.ts';
19
20
  import { ProxmoxLxc } from './lxc.ts';
20
21
  import type { LxcProps } from './lxc-props.ts';
21
22
 
22
- const NODE = 'pve1';
23
- const VMID = 100;
24
- const KEY = `${NODE}/${String(VMID)}`;
25
23
  const TAKEOVER = 'already exists, and this stack holds no state for it';
26
24
 
27
- const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
28
- const { lxc: _raw, ...config } = LIVE;
29
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
30
- };
31
-
32
25
  let fake: FakePve | undefined;
33
26
  afterEach(() => {
34
27
  fake?.stop();
@@ -146,14 +139,26 @@ describe('an interrupted create', () => {
146
139
  expect(stack.status('door')).toBe('created');
147
140
  });
148
141
 
149
- test('asks for --adopt when the guest changed meanwhile, and resumes with it', async () => {
142
+ test('is our own guest, not an adoption: a declaration changed meanwhile is written', async () => {
143
+ const { pve, stack } = await interrupted();
144
+ const run = await stack.deploy(door({ hostname: 'door-renamed' }));
145
+ expect(run.failure).toBe('');
146
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`, `PUT nodes/${NODE}/lxc/150/config`]);
147
+ expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('door-renamed');
148
+ });
149
+
150
+ test('asks for --adopt when the guest changed meanwhile, and adopts it only as it runs', async () => {
150
151
  const { pve, stack } = await interrupted();
151
152
  const guest = pve.guests.get(`${NODE}/150`) ?? {};
152
153
  guest['hostname'] = 'someone-else';
153
154
  expect((await stack.deploy(door())).failure).toContain('Cannot resume creating');
154
- const resumed = await stack.deploy(door(), { adopt: true });
155
+ const refused = await stack.deploy(door(), { adopt: true });
156
+ expect(refused.failure).toContain('adopting it would change hostname');
157
+ expect(refused.failure).not.toContain('someone-else');
158
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
159
+ const resumed = await stack.deploy(door({ hostname: 'someone-else' }), { adopt: true });
155
160
  expect(resumed.failure).toBe('');
156
- expect(resumed.warnings.join('\n')).toContain('differs from the declaration in hostname');
157
- expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('door-example');
161
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
162
+ expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('someone-else');
158
163
  });
159
164
  });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * AN ADOPTION NEVER CHANGES A GUEST (lxc-adoption.ts), through Alchemy's real plan and apply over
3
+ * fake-pve-lxc.ts (placeholders only).
4
+ *
5
+ * ⛔ WHAT THESE PIN: a cold adoption that differs from the live guest in ANY key fails the plan —
6
+ * under `adopt(true)` and under `--adopt` alike — naming the keys and none of the values, before
7
+ * `judge`'s own refusals (which print a declared value); nothing is written and no row is left.
8
+ * A hand edit between the plan and the deploy is refused at apply with nothing written; the
9
+ * adoption row it leaves keeps refusing until the declaration says what is live, and then adopts
10
+ * with no write. An interrupted create that `--adopt` resumes is held to the same rule:
11
+ * lxc-ownership.test.ts.
12
+ * ★ MUTATION-CHECKED 2026-09-21: removing either `refuseAdoptedDrift` call in lxc.ts, or any one of
13
+ * the three adoption answers in ownership/adopting.ts, fails a test here or in
14
+ * lxc-ownership.test.ts.
15
+ */
16
+ import { afterEach, describe, expect, test } from 'bun:test';
17
+ import { adopt } from 'alchemy/AdoptPolicy';
18
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
19
+ import { KEY, NODE, VMID, lxcEngine, pasted, seed } from './lxc-harness.ts';
20
+ import { ProxmoxLxc } from './lxc.ts';
21
+ import type { LxcProps } from './lxc-props.ts';
22
+
23
+ const REFUSAL = 'adopting it would change';
24
+
25
+ let fake: FakePve | undefined;
26
+ afterEach(() => {
27
+ fake?.stop();
28
+ fake = undefined;
29
+ });
30
+ const live = () => {
31
+ fake = fakePve();
32
+ seed(fake, NODE, VMID);
33
+ return fake;
34
+ };
35
+
36
+ const drifted = (): LxcProps =>
37
+ pasted({ memory: 4096, net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000' });
38
+
39
+ describe('a cold adoption that differs from the live guest fails the plan', () => {
40
+ test.each([
41
+ ['adopt(true)', () => ProxmoxLxc('ct', drifted()).pipe(adopt(true)), {}],
42
+ ['--adopt', () => ProxmoxLxc('ct', drifted()), { adopt: true }],
43
+ ])('under %s: keys named, values never, nothing written', async (_, declare, options) => {
44
+ const pve = live();
45
+ const stack = lxcEngine(pve);
46
+ const planned = await stack.plan(declare(), options);
47
+ expect(planned.failure).toContain(`CT ${String(VMID)} on ${NODE}: ${REFUSAL} memory, net1.`);
48
+ for (const value of ['4096', '8192', '9000', '1500']) {
49
+ expect(planned.failure).not.toContain(value);
50
+ }
51
+ const deployed = await stack.deploy(declare(), options);
52
+ expect(deployed.failure).toContain(`${REFUSAL} memory, net1`);
53
+ expect(writesOf(pve)).toEqual([]);
54
+ expect(stack.status('ct')).toBeUndefined();
55
+ });
56
+
57
+ test.each([
58
+ ['a device passthrough', { dev1: '/dev/fuse' }, 'dev1', ['/dev/fuse', 'pct set']],
59
+ ['an unprivileged flip', { unprivileged: 0 as const }, 'unprivileged', ['live 1']],
60
+ [
61
+ 'a smaller mount point',
62
+ { mp0: 'tank:subvol-100-disk-0,mp=/data,size=100G' },
63
+ 'mp0',
64
+ ['100G'],
65
+ ],
66
+ ])('%s is the adoption refusal, not the update one', async (_, change, key, hidden) => {
67
+ const pve = live();
68
+ const run = await lxcEngine(pve).plan(ProxmoxLxc('ct', pasted(change)).pipe(adopt(true)));
69
+ expect(run.failure).toContain(`${REFUSAL} ${key}.`);
70
+ for (const value of hidden) expect(run.failure).not.toContain(value);
71
+ expect(writesOf(pve)).toEqual([]);
72
+ });
73
+
74
+ test('an exact match still adopts, with no write and no warning', async () => {
75
+ const pve = live();
76
+ const run = await lxcEngine(pve).deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)));
77
+ expect(run).toEqual({ actions: { ct: 'adopted' }, failure: '', warnings: [] });
78
+ expect(writesOf(pve)).toEqual([]);
79
+ });
80
+ });
81
+
82
+ describe('a hand edit between the plan and the deploy', () => {
83
+ test('is refused at apply, and the adoption holds until the declaration says what is live', async () => {
84
+ const pve = live();
85
+ const stack = lxcEngine(pve);
86
+ const declare = (over: Partial<LxcProps> = {}) =>
87
+ ProxmoxLxc('ct', pasted(over)).pipe(adopt(true));
88
+ // ★ Two config reads plan a cold adoption — the probe's and diff's — so the edit lands after
89
+ // the plan said `adopted` and before reconcile reads.
90
+ pve.edits.set(KEY, { after: 2, set: { memory: 4096 } });
91
+ const raced = await stack.deploy(declare());
92
+ expect(raced.failure).toContain(`${REFUSAL} memory.`);
93
+ expect(raced.failure).not.toContain('4096');
94
+ expect(writesOf(pve)).toEqual([]);
95
+ expect(pve.guests.get(KEY)?.['memory']).toBe(4096);
96
+ expect(stack.status('ct')).toBe('updating');
97
+ expect((await stack.plan(declare())).failure).toContain(`${REFUSAL} memory.`);
98
+ const caughtUp = await stack.deploy(declare({ memory: 4096 }));
99
+ expect(caughtUp.failure).toBe('');
100
+ expect(writesOf(pve)).toEqual([]);
101
+ expect(stack.status('ct')).toBe('updated');
102
+ expect((await stack.plan(declare({ memory: 4096 }))).actions).toEqual({ ct: 'noop' });
103
+ });
104
+ });
@@ -17,11 +17,11 @@
17
17
  * ⛔ AND NOTHING HERE WRITES A VOLUME IT DID NOT ALLOCATE. A create takes `storage:GiB` only
18
18
  * (lxc-create-form.ts), and a deploy that planned a create never writes onto a guest it finds.
19
19
  *
20
- * ⚠️ AN ADOPTION ALWAYS PLANS `adopted`, EVEN WHEN IT WOULD WRITE. alchemy 2.0.0-beta.79 turns a
21
- * cold adoption's diff into an update whatever it answered (Plan.ts, `forceUpdateAfterAdoption`)
22
- * and prints no property diff for it. So `diff` LOGS the keys a deploy would write, by name
23
- * (never by value), as a warning — the only place a plan can say the declaration is not the live
24
- * config yet. A clean adoption logs nothing, and its deploy writes nothing (lxc-judge.ts).
20
+ * ⛔ AND AN ADOPTION NEVER CHANGES A GUEST (lxc-adoption.ts). A guest this stack adopts is taken
21
+ * over exactly as it runs: any key the declaration says otherwise FAILS the plan, by name (never
22
+ * by value), and reconcile asks again before it would write. Only an exact match adopts.
23
+ * ⚠️ AN UPDATE STILL PLANS `update` WITHOUT A PROPERTY DIFF, so `diff` LOGS the keys a deploy would
24
+ * write, by name, as a warning — the only place a plan says what the deploy will change.
25
25
  *
26
26
  * ⚠️ POWER STATE IS NOT DECLARED. `onboot` is config; start and stop are an operator's act, as for
27
27
  * `Proxmox.Vm`. `start: 1` starts a guest once, after the create that built it, and never again.
@@ -36,6 +36,7 @@ import { ownedRead } from '../ownership/probe.ts';
36
36
  import { provingResumes } from '../ownership/resume.ts';
37
37
  import { createRefusals } from './lxc-create-form.ts';
38
38
  import { identityRefusals } from './lxc-identity.ts';
39
+ import { refuseAdoptedDrift } from './lxc-adoption.ts';
39
40
  import { judge } from './lxc-judge.ts';
40
41
  import {
41
42
  LxcRefusedError,
@@ -75,8 +76,9 @@ const refused = (reasons: readonly string[]) =>
75
76
  * that follows, so reconcile can tell our own interrupted create from a guest somebody else put
76
77
  * at this vmid.
77
78
  */
78
- const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | undefined) =>
79
+ const diff = (owner: LxcOwner, news: Input<LxcProps>, olds: LxcProps) =>
79
80
  Effect.gen(function* () {
81
+ const { output } = owner;
80
82
  const identity = identityRefusals(news, olds, output);
81
83
  if (identity.length > 0) return yield* refused(identity);
82
84
  if (output === undefined) return undefined;
@@ -96,6 +98,7 @@ const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | und
96
98
  return { action: 'update' } as const;
97
99
  }
98
100
  const change = judge(news, live);
101
+ yield* refuseAdoptedDrift(owner, news, live, change);
99
102
  if (change.refuse.length > 0) return yield* refused(change.refuse);
100
103
  const moved = news.node !== output.node;
101
104
  if (change.drift.length === 0 && !moved) return { action: 'noop' } as const;
@@ -112,7 +115,8 @@ const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | und
112
115
  /**
113
116
  * ⛔ THE SAME JUDGE AS `diff`, RE-RUN AGAINST A FRESH READ, AND AN EMPTY CHANGE WRITES NOTHING.
114
117
  * Apply routes `adopted` through reconcile (resource.ts has the Apply.ts lines), so this is what
115
- * runs on the first deploy after an adoption; for a guest that already matches it is two GETs.
118
+ * runs on the first deploy after an adoption; for a guest that already matches it is two GETs,
119
+ * and for one edited since the plan it is a refusal, never a write (lxc-adoption.ts).
116
120
  * ⛔ AND IT READS BACK AND JUDGES AGAIN. PVE answers 200 on writes that did nothing, so a change
117
121
  * that did not land is a failure here, not a state row claiming it did. ⚠️ A change PVE parked
118
122
  * as pending on a running guest DOES read back (GET config merges `[pending]` unless asked for
@@ -148,6 +152,7 @@ const reconcile = (owner: LxcOwner, news: LxcProps) =>
148
152
  'onto an existing guest: plan again, so the plan can show it and adopt it.',
149
153
  ]);
150
154
  }
155
+ yield* refuseAdoptedDrift(owner, news, live, change);
151
156
  if (change.refuse.length > 0) return yield* refused(change.refuse);
152
157
  if (change.drift.length > 0) yield* updateGuest(news, change, text(live['digest']));
153
158
  }
@@ -208,14 +213,13 @@ const handlers = {
208
213
  delete: ({ olds, output }: { olds: LxcProps; output: LxcAttributes }) =>
209
214
  destroyGuest(whereOf(olds, output), olds),
210
215
  diff: ({
216
+ fqn,
217
+ instanceId,
211
218
  news,
212
219
  olds,
213
220
  output,
214
- }: {
215
- news: Input<LxcProps>;
216
- olds: LxcProps;
217
- output: LxcAttributes | undefined;
218
- }) => diff(news, olds, output),
221
+ }: Asked & { news: Input<LxcProps>; olds: LxcProps; output: LxcAttributes | undefined }) =>
222
+ diff({ fqn, instanceId, output }, news, olds),
219
223
  list: () => Effect.succeed([]),
220
224
  read: ({
221
225
  fqn,