@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.
- package/README.md +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +370 -0
- package/dist/verify/cli.js.map +17 -0
- package/dist/verify/fake-engine.d.ts +65 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +347 -0
- package/dist/verify/index.js.map +16 -0
- package/dist/verify/plan-scope.d.ts +14 -0
- package/dist/verify/plan-scope.d.ts.map +1 -0
- package/dist/verify/recheck.d.ts +42 -0
- package/dist/verify/recheck.d.ts.map +1 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +67 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +43 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +119 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +182 -0
- package/src/verify/index.ts +37 -0
- package/src/verify/plan-scope.ts +34 -0
- package/src/verify/recheck.test.ts +56 -0
- package/src/verify/recheck.ts +71 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +210 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +160 -0
- package/src/verify/verify.test.ts +146 -0
- package/src/verify/verify.ts +185 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes a `Proxmox.Lxc` a DIFFERENT guest: a new vmid, or another template. Judged by `diff`
|
|
3
|
+
* and `reconcile` in lxc.ts before anything else, and never planned as a replace — see lxc.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
6
|
+
|
|
7
|
+
/** A prop's value if it is already plain data, else undefined (an unresolved Output). */
|
|
8
|
+
const plain = (news: unknown, key: string): unknown => {
|
|
9
|
+
const value: unknown = (news as Record<string, unknown>)[key];
|
|
10
|
+
return typeof value === 'string' || typeof value === 'number' ? value : undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ⛔ IDENTITY IS JUDGED BEFORE `isResolved(news)`. That guard returns early when ANY prop is still
|
|
15
|
+
* an Output, and the engine then plans `update` — which, for a new vmid, would build a second
|
|
16
|
+
* guest and orphan the first (the openbao rename lesson, rename.ts). A template is identity
|
|
17
|
+
* too: it is consumed at create, so a different one is a different guest.
|
|
18
|
+
* ★ `node` IS NOT JUDGED HERE, BECAUSE A NODE CHANGE CAN BE THE DECLARATION CATCHING UP. HA fails
|
|
19
|
+
* a guest over, or somebody runs `pct migrate`; the guest is on its new node before any line
|
|
20
|
+
* says so. `readLive` at the declared node settles both cases: the guest is there (follow it,
|
|
21
|
+
* and re-record state with a write-free update), or it is elsewhere and the read FAILS naming
|
|
22
|
+
* where — asking to move a guest is a migration, and this resource does not migrate.
|
|
23
|
+
*/
|
|
24
|
+
export const identityRefusals = (
|
|
25
|
+
news: unknown,
|
|
26
|
+
olds: LxcProps,
|
|
27
|
+
output: LxcAttributes | undefined,
|
|
28
|
+
): string[] => {
|
|
29
|
+
const was = output?.vmid ?? olds.vmid;
|
|
30
|
+
const vmid = plain(news, 'vmid');
|
|
31
|
+
const template = plain(news, 'ostemplate');
|
|
32
|
+
const reasons: string[] = [];
|
|
33
|
+
if (vmid !== undefined && vmid !== was) {
|
|
34
|
+
reasons.push(
|
|
35
|
+
`vmid: ${String(was)} -> ${String(vmid)} is a different guest. Declare it as a new ` +
|
|
36
|
+
'resource; this one is retained when its declaration goes.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (template !== undefined && olds.ostemplate !== undefined && template !== olds.ostemplate) {
|
|
40
|
+
reasons.push('ostemplate: a template is consumed at create; another one is a new guest.');
|
|
41
|
+
}
|
|
42
|
+
return reasons;
|
|
43
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of `Proxmox.Lxc`: which spellings are one value, what a change writes, and what is
|
|
3
|
+
* refused — no server. The engine-level tests beside this one prove the same rules survive plan
|
|
4
|
+
* and apply; these pin each rule on its own, with PVE's reason next to it.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { TARGET } from './lxc-harness.ts';
|
|
8
|
+
import { createForm, createRefusals } from './lxc-create-form.ts';
|
|
9
|
+
import { judge } from './lxc-judge.ts';
|
|
10
|
+
import { type LxcProps, rawKeysOf, storedConfig } from './lxc-props.ts';
|
|
11
|
+
import { judgeVolume, parseVolume, sizeBytes } from './lxc-volume.ts';
|
|
12
|
+
|
|
13
|
+
const props = (over: Partial<LxcProps>): LxcProps => ({
|
|
14
|
+
node: 'pve1',
|
|
15
|
+
target: TARGET,
|
|
16
|
+
vmid: 100,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
const live = (config: Record<string, unknown>) => ({ unprivileged: 1, ...config });
|
|
20
|
+
|
|
21
|
+
describe('sizes and volumes, as pve-container parses them', () => {
|
|
22
|
+
test('parse_size: binary units, bare bytes, and nothing else', () => {
|
|
23
|
+
expect(sizeBytes('40G')).toBe(40 * 1024 ** 3);
|
|
24
|
+
expect(sizeBytes('1.5T')).toBe(1.5 * 1024 ** 4);
|
|
25
|
+
expect(sizeBytes('512M')).toBe(512 * 1024 ** 2);
|
|
26
|
+
expect(sizeBytes('100')).toBe(100);
|
|
27
|
+
expect(sizeBytes('40GB')).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('classify_mountpoint, plus the new-disk spelling', () => {
|
|
31
|
+
expect(parseVolume('local-zfs:40').kind).toBe('new');
|
|
32
|
+
expect(parseVolume('local-zfs:subvol-1-disk-0,size=40G').kind).toBe('volume');
|
|
33
|
+
expect(parseVolume('/srv/share,mp=/share').kind).toBe('bind');
|
|
34
|
+
expect(parseVolume('/dev/sdb1,mp=/disk').kind).toBe('device');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('an options change on a volume declared as new is written with the LIVE volume id', () => {
|
|
38
|
+
const verdict = judgeVolume('mp0', 'tank:200,mp=/data,backup=1', LIVE_MP);
|
|
39
|
+
expect(verdict).toEqual({ put: 'tank:subvol-100-disk-0,mp=/data,backup=1,size=200G' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a fractional new-disk size grows by the same GiB', () => {
|
|
43
|
+
expect(judgeVolume('mp0', 'tank:200.5,mp=/data', LIVE_MP)).toEqual({ resize: '200.5G' });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const LIVE_MP = 'tank:subvol-100-disk-0,mp=/data,backup=0,size=200G';
|
|
48
|
+
|
|
49
|
+
describe('scalars compare the way PVE stores them', () => {
|
|
50
|
+
test.each([
|
|
51
|
+
[
|
|
52
|
+
'a description PVE handed back with a newline',
|
|
53
|
+
{ description: 'hi' },
|
|
54
|
+
{ description: 'hi\n' },
|
|
55
|
+
],
|
|
56
|
+
['tags in another order and case', { tags: 'B;a' }, { tags: 'a;b' }],
|
|
57
|
+
['a default PVE leaves out', { onboot: 0, cpulimit: 0, swap: 512 }, {}],
|
|
58
|
+
['startup with its default key bare', { startup: '1,up=30' }, { startup: 'order=1,up=30' }],
|
|
59
|
+
['a boolean as true', { protection: true }, { protection: 1 }],
|
|
60
|
+
[
|
|
61
|
+
'features with an off key spelled out',
|
|
62
|
+
{ features: 'nesting=1,keyctl=0' },
|
|
63
|
+
{ features: 'nesting=1' },
|
|
64
|
+
],
|
|
65
|
+
])('%s is no drift', (_, declared, config) => {
|
|
66
|
+
expect(judge(props(declared as Partial<LxcProps>), live(config)).drift).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('cores absent means every host core, which no number is', () => {
|
|
70
|
+
expect(judge(props({ cores: 4 }), live({})).put).toEqual({ cores: '4' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('nameserver order is meaningful', () => {
|
|
74
|
+
const change = judge(props({ nameserver: 'b a' }), live({ nameserver: 'a b' }));
|
|
75
|
+
expect(change.put).toEqual({ nameserver: 'b a' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("'' clears a clearable key and refuses one that cannot be removed", () => {
|
|
79
|
+
expect(judge(props({ description: '' }), live({ description: 'x\n' })).clear).toEqual([
|
|
80
|
+
'description',
|
|
81
|
+
]);
|
|
82
|
+
expect(judge(props({ hostname: '' }), live({ hostname: 'x' })).refuse[0]).toMatch(
|
|
83
|
+
/cannot be removed/,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a key this resource does not manage is refused, not written', () => {
|
|
88
|
+
const odd = props({}) as unknown as Record<string, unknown>;
|
|
89
|
+
odd['hookscript'] = 'local:snippets/x.sh';
|
|
90
|
+
expect(judge(odd as unknown as LxcProps, live({})).refuse[0]).toMatch(/not a config key/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('features follow check_ct_modify_config_perm', () => {
|
|
95
|
+
test('nesting alone on an unprivileged guest is an update', () => {
|
|
96
|
+
expect(judge(props({ features: 'nesting=1' }), live({})).put).toEqual({
|
|
97
|
+
features: 'nesting=1',
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('any features write on a privileged guest is root@pam only', () => {
|
|
102
|
+
const change = judge(props({ features: 'nesting=1' }), { unprivileged: 0 });
|
|
103
|
+
expect(change.refuse[0]).toMatch(/root@pam/);
|
|
104
|
+
expect(change.put).toEqual({});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('clearing features that hold more than nesting is root@pam only, as PVE counts keys', () => {
|
|
108
|
+
const change = judge(props({ features: '' }), live({ features: 'nesting=1,keyctl=0' }));
|
|
109
|
+
expect(change.refuse[0]).toMatch(/pct set 100 --delete features/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('NICs and devices', () => {
|
|
114
|
+
test("net '' removes a NIC; a new NIC is added as declared", () => {
|
|
115
|
+
const change = judge(props({ net1: '', net2: 'name=eth2,bridge=vmbr1' }), live({ net1: 'x' }));
|
|
116
|
+
expect(change.clear).toEqual(['net1']);
|
|
117
|
+
expect(change.put).toEqual({ net2: 'name=eth2,bridge=vmbr1' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a declared MAC is compared, case-insensitively', () => {
|
|
121
|
+
const net = 'name=eth0,bridge=vmbr0,hwaddr=00:00:5E:00:53:01,type=veth';
|
|
122
|
+
expect(judge(props({ net0: net.toLowerCase() }), live({ net0: net })).drift).toEqual([]);
|
|
123
|
+
const other = judge(props({ net0: net.replace(':01', ':09') }), live({ net0: net }));
|
|
124
|
+
expect(other.drift).toEqual(['net0']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a device with deny-write=0 is the same device; a new one is root@pam's", () => {
|
|
128
|
+
const dev = '/dev/net/tun,deny-write=0';
|
|
129
|
+
expect(judge(props({ dev0: dev }), live({ dev0: '/dev/net/tun' })).drift).toEqual([]);
|
|
130
|
+
expect(judge(props({ dev0: dev }), live({})).refuse[0]).toMatch(/Device passthrough/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('create', () => {
|
|
135
|
+
test('the form carries declared keys, drops empties, and sends features without off keys', () => {
|
|
136
|
+
const form = createForm(
|
|
137
|
+
props({
|
|
138
|
+
description: '',
|
|
139
|
+
features: 'nesting=1,keyctl=0',
|
|
140
|
+
ostemplate: 'local:vztmpl/t.tar.zst',
|
|
141
|
+
start: true,
|
|
142
|
+
unprivileged: true,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
expect(form).toEqual({
|
|
146
|
+
features: 'nesting=1',
|
|
147
|
+
ostemplate: 'local:vztmpl/t.tar.zst',
|
|
148
|
+
start: '1',
|
|
149
|
+
unprivileged: '1',
|
|
150
|
+
vmid: '100',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a privileged create with any feature is refused', () => {
|
|
155
|
+
const refusals = createRefusals(
|
|
156
|
+
props({ features: 'nesting=1', ostemplate: 't', unprivileged: false }),
|
|
157
|
+
);
|
|
158
|
+
expect(refusals.join('\n')).toMatch(/Any feature on a privileged guest/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('what state keeps', () => {
|
|
163
|
+
test('never the notes, digest, lock, env or raw lxc values; raw keys by name only', () => {
|
|
164
|
+
const read = {
|
|
165
|
+
description: 'notes a human wrote\n',
|
|
166
|
+
digest: 'd',
|
|
167
|
+
env: 'A=1',
|
|
168
|
+
hostname: 'x',
|
|
169
|
+
lock: 'backup',
|
|
170
|
+
lxc: [['lxc.a', 'v']],
|
|
171
|
+
};
|
|
172
|
+
expect(storedConfig(read)).toEqual({ hostname: 'x' });
|
|
173
|
+
expect(rawKeysOf(read)).toEqual(['lxc.a']);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared against live: what a deploy would write to a container, and what it must refuse.
|
|
3
|
+
*
|
|
4
|
+
* ★ ONE PURE FUNCTION DECIDES FOR BOTH `diff` AND `reconcile`, so the plan and the write cannot
|
|
5
|
+
* disagree about what is drift. This is resource.ts's `matches` guard grown up: an adopted guest
|
|
6
|
+
* that already says what the declaration says produces an empty change, and an empty change is
|
|
7
|
+
* not a write — adopting a running container must cost nothing.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ SOME CHANGES NO API TOKEN CAN MAKE, AND THEY ARE REFUSED AT PLAN RATHER THAN 403'd MID-DEPLOY.
|
|
10
|
+
* `PVE::LXC::check_ct_modify_config_perm` (pve-container 6.1.14) returns early only for
|
|
11
|
+
* `$authuser eq 'root@pam'`, and a token's authuser is `root@pam!name` at best. So for a token:
|
|
12
|
+
* - `devN` — "configuring device passthrough is only allowed for root@pam";
|
|
13
|
+
* - a bind or device `mpN`/`rootfs` — "mount point type … is only allowed for root@pam";
|
|
14
|
+
* - `features` on a privileged guest, or any feature but `nesting` — root@pam only.
|
|
15
|
+
* Each refusal names the `pct set` to run on the node instead. After it, declare the value and
|
|
16
|
+
* the next plan compares it like any other key.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
type LxcProps,
|
|
20
|
+
declaredKeys,
|
|
21
|
+
declaredValue,
|
|
22
|
+
indexedKey,
|
|
23
|
+
isManagedKey,
|
|
24
|
+
wireValue,
|
|
25
|
+
} from './lxc-props.ts';
|
|
26
|
+
import { isHostPath, judgeVolume, parseVolume } from './lxc-volume.ts';
|
|
27
|
+
import {
|
|
28
|
+
CLEARABLE,
|
|
29
|
+
SCALAR_DEFAULTS,
|
|
30
|
+
netMaps,
|
|
31
|
+
netWrite,
|
|
32
|
+
optionMap,
|
|
33
|
+
pairs,
|
|
34
|
+
sameMap,
|
|
35
|
+
scalarForm,
|
|
36
|
+
sortedForm,
|
|
37
|
+
} from './lxc-wire.ts';
|
|
38
|
+
import { bool, canonicalToken } from './values.ts';
|
|
39
|
+
|
|
40
|
+
export type LxcChange = {
|
|
41
|
+
/** Keys to `PUT …/config`, as PVE's wire strings. */
|
|
42
|
+
readonly put: Record<string, string>;
|
|
43
|
+
/** Keys to remove with `delete=`. */
|
|
44
|
+
readonly clear: readonly string[];
|
|
45
|
+
/** Mount points to grow, one `PUT …/resize` each, after the config PUT. */
|
|
46
|
+
readonly resize: readonly { readonly disk: string; readonly size: string }[];
|
|
47
|
+
/** Why the change cannot be made as an update. Non-empty means nothing is written. */
|
|
48
|
+
readonly refuse: readonly string[];
|
|
49
|
+
/** Every declared key that differs from live, refused or not — what a plan should name. */
|
|
50
|
+
readonly drift: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const text = (value: unknown): string | undefined =>
|
|
54
|
+
value === undefined || value === null ? undefined : wireValue(value);
|
|
55
|
+
|
|
56
|
+
/** A refusal naming the `pct set` to run instead. `''` is a removal, so it names `--delete`. */
|
|
57
|
+
export const rootOnly = (vmid: number, key: string, value: string, why: string): string =>
|
|
58
|
+
`${key}: ${why} PVE lets only root@pam write it, and an API token is never root@pam. Run ` +
|
|
59
|
+
`\`pct set ${String(vmid)} ${value === '' ? `--delete ${key}` : `--${key} '${value}'`}\` ` +
|
|
60
|
+
'on the node, then plan again.';
|
|
61
|
+
|
|
62
|
+
/** `features` parsed the way PVE's `parse_features` does: booleans as 1/0, nothing dropped. */
|
|
63
|
+
const rawFeatures = (value: string) =>
|
|
64
|
+
new Map(
|
|
65
|
+
pairs(value).map(([key, entry]) => [key, key === 'mount' ? entry : canonicalToken(entry)]),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* PVE's own `features` rule, on the value actually SENT (`sent` undefined means `delete=features`):
|
|
70
|
+
* nesting alone needs `VM.Allocate`, anything else is root@pam's.
|
|
71
|
+
* ⚠️ MIRRORED KEY FOR KEY, DEFAULTS INCLUDED, because PVE does not drop them: a live `keyctl=0`
|
|
72
|
+
* against a sent value without `keyctl` is a change to PVE (`'0' ne ''`) and makes the write
|
|
73
|
+
* root-only. `scalarForm` drops off keys from what is sent for the same reason.
|
|
74
|
+
*/
|
|
75
|
+
export const featuresNeedRoot = (
|
|
76
|
+
live: string,
|
|
77
|
+
sent: string | undefined,
|
|
78
|
+
unprivileged: boolean,
|
|
79
|
+
): boolean => {
|
|
80
|
+
if (!unprivileged) return true;
|
|
81
|
+
const have = rawFeatures(live);
|
|
82
|
+
if (sent === undefined) return !(have.size === 0 || (have.size === 1 && have.has('nesting')));
|
|
83
|
+
const want = rawFeatures(sent);
|
|
84
|
+
const keys = new Set([...have.keys(), ...want.keys()]);
|
|
85
|
+
return [...keys].some(
|
|
86
|
+
(key) => key !== 'nesting' && (have.get(key) ?? '') !== (want.get(key) ?? ''),
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const featureWhy = (unprivileged: boolean): string =>
|
|
91
|
+
unprivileged ? 'A feature other than nesting.' : 'Any feature on a privileged guest.';
|
|
92
|
+
|
|
93
|
+
export const judge = (props: LxcProps, live: Record<string, unknown>): LxcChange => {
|
|
94
|
+
const put: Record<string, string> = {};
|
|
95
|
+
const clear: string[] = [];
|
|
96
|
+
const resize: { disk: string; size: string }[] = [];
|
|
97
|
+
const refuse: string[] = [];
|
|
98
|
+
const drift = new Set<string>();
|
|
99
|
+
const unprivileged = bool(live['unprivileged'], false);
|
|
100
|
+
|
|
101
|
+
for (const key of declaredKeys(props)) {
|
|
102
|
+
const want = wireValue(declaredValue(props, key));
|
|
103
|
+
const have = text(live[key]);
|
|
104
|
+
const indexed = indexedKey(key);
|
|
105
|
+
|
|
106
|
+
if (key === 'unprivileged') {
|
|
107
|
+
if (canonicalToken(want) !== canonicalToken(have ?? '0')) {
|
|
108
|
+
drift.add(key);
|
|
109
|
+
refuse.push(
|
|
110
|
+
`unprivileged: live ${have ?? '0'}, declared ${want}. PVE refuses to change it ("unable ` +
|
|
111
|
+
'to modify read-only option"); only a backup and restore can, and that is a new guest.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (key === 'rootfs' || indexed?.[0] === 'mp') {
|
|
118
|
+
if (want === '') {
|
|
119
|
+
if (have === undefined) continue;
|
|
120
|
+
drift.add(key);
|
|
121
|
+
refuse.push(
|
|
122
|
+
`${key}: detaching a mount point moves its volume to unusedN -- do it by hand.`,
|
|
123
|
+
);
|
|
124
|
+
} else if (have === undefined) {
|
|
125
|
+
drift.add(key);
|
|
126
|
+
if (isHostPath(parseVolume(want))) {
|
|
127
|
+
refuse.push(rootOnly(props.vmid, key, want, 'A bind or device mount point.'));
|
|
128
|
+
} else put[key] = want;
|
|
129
|
+
} else {
|
|
130
|
+
const verdict = judgeVolume(key, want, have);
|
|
131
|
+
if (verdict.refuse !== undefined) refuse.push(verdict.refuse);
|
|
132
|
+
if (verdict.rootOnly === true) {
|
|
133
|
+
refuse.push(rootOnly(props.vmid, key, want, 'A bind or device mount point.'));
|
|
134
|
+
}
|
|
135
|
+
if (verdict.put !== undefined) put[key] = verdict.put;
|
|
136
|
+
if (verdict.resize !== undefined) resize.push({ disk: key, size: verdict.resize });
|
|
137
|
+
if (Object.keys(verdict).length > 0) drift.add(key);
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (indexed?.[0] === 'dev') {
|
|
143
|
+
const same = want === '' ? have === undefined : have !== undefined && devSame(want, have);
|
|
144
|
+
if (!same) {
|
|
145
|
+
drift.add(key);
|
|
146
|
+
refuse.push(rootOnly(props.vmid, key, want, 'Device passthrough.'));
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (indexed?.[0] === 'net') {
|
|
152
|
+
if (want === '') {
|
|
153
|
+
if (have !== undefined) {
|
|
154
|
+
drift.add(key);
|
|
155
|
+
clear.push(key);
|
|
156
|
+
}
|
|
157
|
+
} else if (have === undefined) {
|
|
158
|
+
drift.add(key);
|
|
159
|
+
put[key] = want;
|
|
160
|
+
} else {
|
|
161
|
+
const maps = netMaps(want, have);
|
|
162
|
+
if (!sameMap(maps.want, maps.have)) {
|
|
163
|
+
drift.add(key);
|
|
164
|
+
put[key] = netWrite(want, have);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!isManagedKey(key)) {
|
|
171
|
+
drift.add(key);
|
|
172
|
+
refuse.push(`${key}: not a config key this resource manages.`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const wanted = want === '' ? '' : scalarForm(key, want);
|
|
177
|
+
const current = have ?? SCALAR_DEFAULTS[key];
|
|
178
|
+
const had = current === undefined ? '' : scalarForm(key, current);
|
|
179
|
+
if (wanted === had) continue;
|
|
180
|
+
drift.add(key);
|
|
181
|
+
// ⚠️ `features` goes out with its off keys dropped — see `featuresNeedRoot`.
|
|
182
|
+
const next = key === 'features' ? wanted : want;
|
|
183
|
+
if (key === 'features' && featuresNeedRoot(have ?? '', next || undefined, unprivileged)) {
|
|
184
|
+
refuse.push(rootOnly(props.vmid, key, want, featureWhy(unprivileged)));
|
|
185
|
+
} else if (next === '' && CLEARABLE.has(key)) {
|
|
186
|
+
clear.push(key);
|
|
187
|
+
} else if (next === '') {
|
|
188
|
+
refuse.push(`${key}: cannot be removed; declare the value it should have.`);
|
|
189
|
+
} else put[key] = next;
|
|
190
|
+
}
|
|
191
|
+
return { clear, drift: [...drift].sort(), put, refuse, resize };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Two `devN` values as one device: same path, same options, `deny-write=0` meaning absent. */
|
|
195
|
+
const devSame = (want: string, have: string) =>
|
|
196
|
+
sortedForm(optionMap(want, 'dev', 'path')) === sortedForm(optionMap(have, 'dev', 'path'));
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calls a `Proxmox.Lxc` makes: read one config, create, update in place, grow, delete.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A READ FAILURE IS A FAILURE HERE, NOT "ABSENT" — THE ONE PLACE THIS PACKAGE DEPARTS FROM
|
|
5
|
+
* resource.ts. `pveOperations.read` folds every error into `undefined`, which for a guest means a
|
|
6
|
+
* 403 or a node that did not answer plans a CREATE over a running container. PVE answers a
|
|
7
|
+
* missing guest with HTTP 500, `Configuration file 'nodes/<node>/lxc/<vmid>.conf' does not
|
|
8
|
+
* exist` (pve-guest-common `AbstractConfig::load_config`), so only a 500 may mean absent — and
|
|
9
|
+
* even then only after the cluster says so, below. Anything else fails the plan.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ "ABSENT" IS DECIDED BY THE WHOLE CLUSTER, NOT BY THE ERROR TEXT. A config lives under its
|
|
12
|
+
* node, so a guest migrated to another node — or a QEMU VM holding the same cluster-wide vmid —
|
|
13
|
+
* also reads as missing at the declared path. `GET /cluster/resources?type=vm` settles it: the
|
|
14
|
+
* vmid nowhere is absent; the vmid anywhere else FAILS the read with where it is; the vmid on
|
|
15
|
+
* the declared node fails with PVE's own error. ★ Keyed on the cluster rather than on matching
|
|
16
|
+
* the message, so a PVE that words it differently still cannot turn into a create.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ CREATE, RESIZE AND DELETE ARE ASYNCHRONOUS; A CONFIG PUT IS NOT. The first three answer a
|
|
19
|
+
* UPID the moment PVE forks the worker (`NodesNodeLxcPostReturn`, `…ResizePutReturn`,
|
|
20
|
+
* `…VmidDeleteReturn` are all `string`), so each waits for its task's exitstatus. `PUT …/config`
|
|
21
|
+
* answers `null` after writing (`…ConfigPutReturn`), so it needs no wait.
|
|
22
|
+
* ⛔ EACH TASK IS POLLED WITH THE CREDENTIAL THAT STARTED IT — see `awaitTask`: only the owner
|
|
23
|
+
* skips `Sys.Audit`. A fresh `provision` mint per write gives the full 300s lease a create needs;
|
|
24
|
+
* a cached lease can be handed out with 60s left (lease-cache.ts).
|
|
25
|
+
*/
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import { PveError, pve, pveWith } from './client.ts';
|
|
28
|
+
import { type PveTarget, mint } from './credentials.ts';
|
|
29
|
+
import { createForm } from './lxc-create-form.ts';
|
|
30
|
+
import { type LxcChange, judge } from './lxc-judge.ts';
|
|
31
|
+
import type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
32
|
+
import { rawKeysOf, storedConfig } from './lxc-props.ts';
|
|
33
|
+
import { awaitTask } from './network-apply-read.ts';
|
|
34
|
+
import { text, withClears } from './values.ts';
|
|
35
|
+
|
|
36
|
+
/** A plan-time or apply-time refusal. Nothing was written when one of these is raised. */
|
|
37
|
+
export class LxcRefusedError extends Error {
|
|
38
|
+
override readonly name = 'LxcRefusedError';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where one guest lives. Its identity, and nothing else. */
|
|
42
|
+
export type LxcWhere = { readonly target: PveTarget; readonly node: string; readonly vmid: number };
|
|
43
|
+
|
|
44
|
+
export const guestPath = (where: LxcWhere) => `nodes/${where.node}/lxc/${String(where.vmid)}`;
|
|
45
|
+
|
|
46
|
+
/** How long each task may run, in one-second polls. ⚠️ A cap, not an estimate. */
|
|
47
|
+
const CREATE_POLLS = 180;
|
|
48
|
+
const TASK_POLLS = 60;
|
|
49
|
+
|
|
50
|
+
const isServerError = (error: unknown): error is PveError =>
|
|
51
|
+
error instanceof PveError && error.status === 500;
|
|
52
|
+
|
|
53
|
+
/** One `GET /cluster/resources?type=vm` row. */
|
|
54
|
+
type GuestRow = { readonly vmid?: unknown; readonly node?: unknown; readonly type?: unknown };
|
|
55
|
+
|
|
56
|
+
/** The live config, or undefined when no guest holds this vmid anywhere in the cluster. */
|
|
57
|
+
export const readLive = (where: LxcWhere) =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const answer = yield* pve<Record<string, unknown> | null>(
|
|
60
|
+
where.target,
|
|
61
|
+
'read',
|
|
62
|
+
'GET',
|
|
63
|
+
`${guestPath(where)}/config`,
|
|
64
|
+
).pipe(
|
|
65
|
+
Effect.map((data) => ({ data, error: undefined })),
|
|
66
|
+
Effect.catchIf(isServerError, (error) => Effect.succeed({ data: undefined, error })),
|
|
67
|
+
);
|
|
68
|
+
if (answer.data !== undefined && answer.data !== null) return answer.data;
|
|
69
|
+
const rows = yield* pve<readonly GuestRow[]>(
|
|
70
|
+
where.target,
|
|
71
|
+
'read',
|
|
72
|
+
'GET',
|
|
73
|
+
'cluster/resources?type=vm',
|
|
74
|
+
);
|
|
75
|
+
// ⚠️ `Number()`, NOT `===` ON THE RAW VALUE: a vmid that came back as `"100"` would otherwise
|
|
76
|
+
// match nothing, and "listed nowhere" is the one answer that turns into a create.
|
|
77
|
+
const found = (rows ?? []).find((row) => Number(row.vmid) === where.vmid);
|
|
78
|
+
if (found !== undefined && found.node === where.node && found.type === 'lxc') {
|
|
79
|
+
return yield* Effect.fail(
|
|
80
|
+
answer.error ??
|
|
81
|
+
new LxcRefusedError(`CT ${String(where.vmid)} is listed but its config read empty.`),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (found !== undefined) {
|
|
85
|
+
return yield* Effect.fail(
|
|
86
|
+
new LxcRefusedError(
|
|
87
|
+
`vmid ${String(where.vmid)} is a ${text(found.type, 'guest')} on ` +
|
|
88
|
+
`${text(found.node, '?')}, not a container on ${where.node}. A migration is not an ` +
|
|
89
|
+
'update and vmids are cluster-wide: declare the node it is on, or pick a free vmid.',
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const attributesOf = (where: LxcWhere, live: Record<string, unknown>): LxcAttributes => ({
|
|
97
|
+
config: storedConfig(live),
|
|
98
|
+
node: where.node,
|
|
99
|
+
rawKeys: rawKeysOf(live),
|
|
100
|
+
vmid: where.vmid,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Run one task-starting call and wait for it on the same credential. Fails unless `OK`. */
|
|
104
|
+
const task = (
|
|
105
|
+
where: LxcWhere,
|
|
106
|
+
method: 'POST' | 'PUT' | 'DELETE',
|
|
107
|
+
path: string,
|
|
108
|
+
what: string,
|
|
109
|
+
polls: number,
|
|
110
|
+
form?: Record<string, string>,
|
|
111
|
+
) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const credential = yield* mint(where.target, 'provision');
|
|
114
|
+
const upid = text(yield* pveWith<string>(where.target, credential, method, path, form));
|
|
115
|
+
if (upid === '') {
|
|
116
|
+
return yield* Effect.fail(
|
|
117
|
+
new LxcRefusedError(
|
|
118
|
+
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const exit = yield* awaitTask(where.target, credential, where.node, upid, polls);
|
|
123
|
+
if (exit !== 'OK') {
|
|
124
|
+
return yield* Effect.fail(
|
|
125
|
+
new LxcRefusedError(
|
|
126
|
+
`${what}: task ${upid} ended "${exit}". Read \`pvesh get /nodes/${where.node}/tasks/` +
|
|
127
|
+
`${upid}/log\` before running the deploy again.`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** POST the create and wait for the template to unpack. ⛔ Refusals are the caller's to check. */
|
|
134
|
+
export const createGuest = (props: LxcProps) =>
|
|
135
|
+
task(
|
|
136
|
+
props,
|
|
137
|
+
'POST',
|
|
138
|
+
`nodes/${props.node}/lxc`,
|
|
139
|
+
`create CT ${String(props.vmid)}`,
|
|
140
|
+
CREATE_POLLS,
|
|
141
|
+
createForm(props),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Apply a judged change: the config PUT first, then each resize.
|
|
146
|
+
* ⚠️ IN THAT ORDER BECAUSE A MOUNT POINT'S PUT CARRIES ITS LIVE `size=`, and the resize rewrites it.
|
|
147
|
+
* ⛔ THE PUT CARRIES THE `digest` OF THE CONFIG IT WAS JUDGED AGAINST. PVE refuses the write if the
|
|
148
|
+
* file changed since (`assert_if_modified`), so an edit landing between reconcile's own read and
|
|
149
|
+
* its PUT fails the deploy instead of being overwritten by a change computed from the older file.
|
|
150
|
+
* ⚠️ THE DIGEST IS THE DEPLOY'S READ, NOT THE PLAN'S. A hand edit between `plan` and `deploy` is
|
|
151
|
+
* seen by reconcile's fresh judge instead: a declared key edited meanwhile is written back to the
|
|
152
|
+
* declaration (even after a plan that warned of nothing), an undeclared one is left alone.
|
|
153
|
+
*/
|
|
154
|
+
export const updateGuest = (props: LxcProps, change: LxcChange, digest: string) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
if (Object.keys(change.put).length > 0 || change.clear.length > 0) {
|
|
157
|
+
const form = withClears(
|
|
158
|
+
{ ...change.put, ...(digest === '' ? {} : { digest }) },
|
|
159
|
+
change.clear,
|
|
160
|
+
);
|
|
161
|
+
yield* pve(props.target, 'provision', 'PUT', `${guestPath(props)}/config`, form);
|
|
162
|
+
}
|
|
163
|
+
for (const grow of change.resize) {
|
|
164
|
+
yield* task(
|
|
165
|
+
props,
|
|
166
|
+
'PUT',
|
|
167
|
+
`${guestPath(props)}/resize`,
|
|
168
|
+
`grow ${grow.disk} of CT ${String(props.vmid)} to ${grow.size}`,
|
|
169
|
+
TASK_POLLS,
|
|
170
|
+
{ disk: grow.disk, size: grow.size },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* ⛔ NO `force`, NO `purge`, NO STOP FIRST. PVE refuses to destroy a running or protected guest,
|
|
177
|
+
* and that refusal is kept: stopping a guest so a delete can proceed is an operator's decision.
|
|
178
|
+
* Reached only under `RemovalPolicy.destroy()` — this resource retains by default.
|
|
179
|
+
* ⛔ AND ONLY THE GUEST THIS STACK DECLARED. State holds a vmid, and a vmid is reusable: after a
|
|
180
|
+
* hand `pct destroy` and a new `pct create` at the same id, "delete CT 150" is somebody else's
|
|
181
|
+
* container and all its volumes. So the live guest is judged against the last declaration
|
|
182
|
+
* first; one that no longer matches is refused with the keys that differ, and deleting it (or
|
|
183
|
+
* declaring it again) is the operator's call.
|
|
184
|
+
*/
|
|
185
|
+
export const destroyGuest = (where: LxcWhere, declared: LxcProps) =>
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
const live = yield* readLive(where);
|
|
188
|
+
if (live === undefined) return;
|
|
189
|
+
const drift = judge(declared, live).drift;
|
|
190
|
+
if (drift.length > 0) {
|
|
191
|
+
return yield* Effect.fail(
|
|
192
|
+
new LxcRefusedError(
|
|
193
|
+
`destroy CT ${String(where.vmid)}: the guest on ${where.node} no longer matches its last ` +
|
|
194
|
+
`declaration (${drift.join(', ')}), so it may not be the guest this stack made. ` +
|
|
195
|
+
'Nothing was deleted. Delete it by hand if it should go, or declare it again.',
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
yield* task(where, 'DELETE', guestPath(where), `destroy CT ${String(where.vmid)}`, TASK_POLLS);
|
|
200
|
+
});
|