@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.
- package/dist/caddy/index.js.map +2 -2
- package/dist/launchd/index.js.map +2 -2
- package/dist/openbao/index.js +10 -8
- package/dist/openbao/index.js.map +4 -4
- package/dist/ownership/adopting.d.ts +34 -0
- package/dist/ownership/adopting.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +15 -5
- package/dist/ownership/probe.d.ts.map +1 -1
- package/dist/ownership/rows.d.ts +12 -0
- package/dist/ownership/rows.d.ts.map +1 -1
- package/dist/ownership/store-fixture.d.ts +10 -0
- package/dist/ownership/store-fixture.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/dist/proxmox/index.js +147 -113
- package/dist/proxmox/index.js.map +8 -6
- package/dist/proxmox/lxc-adoption.d.ts +32 -0
- package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +12 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -1
- package/dist/proxmox/lxc.d.ts +5 -5
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/docs/adopted-deploys.md +4 -2
- package/docs/ownership.md +1 -1
- package/docs/proxmox-lxc-adopt.md +83 -0
- package/docs/proxmox-lxc.md +7 -45
- package/docs/proxmox.md +2 -0
- package/package.json +1 -1
- package/src/ownership/adopting.test.ts +63 -0
- package/src/ownership/adopting.ts +46 -0
- package/src/ownership/ownership.test.ts +1 -34
- package/src/ownership/probe.ts +27 -14
- package/src/ownership/rows.ts +8 -2
- package/src/ownership/store-fixture.ts +50 -0
- package/src/proxmox/fake-pve-lxc.ts +13 -1
- package/src/proxmox/lxc-adopt.test.ts +13 -20
- package/src/proxmox/lxc-adoption.ts +50 -0
- package/src/proxmox/lxc-guards.test.ts +6 -13
- package/src/proxmox/lxc-harness.ts +25 -2
- package/src/proxmox/lxc-ownership.test.ts +19 -14
- package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
- 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')
|
|
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
|
|
8
|
-
* keeps the live MAC and carries the digest; and every change PVE cannot
|
|
9
|
-
* plan with nothing written. Deleting both empty-change guards
|
|
10
|
-
* `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
|
|
11
|
-
* each MEASURED by making that edit and running this file,
|
|
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 {
|
|
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
|
|
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 =
|
|
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: '
|
|
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 =
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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('
|
|
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
|
|
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(
|
|
157
|
-
expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('
|
|
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
|
+
});
|
package/src/proxmox/lxc.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* and
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
|
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,
|