@homeflare/alchemy 0.8.0 → 0.10.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/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 +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -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 +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -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 +108 -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 +88 -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 +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose guest it is — docs/ownership.md applied to `Proxmox.Lxc`, through the real engine over
|
|
3
|
+
* fake-pve-lxc.ts (placeholders only).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHAT THESE PIN: with no state, no live guest is adopted without `--adopt` or `adopt(true)`,
|
|
6
|
+
* not even one that matches the declaration; `adopt(false)` wins over the flag; a deploy that
|
|
7
|
+
* planned a create refuses a guest it then finds, and forgets its `creating` row; under
|
|
8
|
+
* `--adopt` it records a matching one with no write and still refuses one that differs; and a
|
|
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.
|
|
11
|
+
* ★ THE INTERRUPTION IS A CREATE TASK THAT ENDS IN AN ERROR AFTER THE FAKE BUILT THE GUEST — to the
|
|
12
|
+
* engine the same as a deploy killed while it waited on the task: a live guest, and a
|
|
13
|
+
* `creating` row with no attributes.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
17
|
+
import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
|
|
18
|
+
import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
|
|
19
|
+
import { ProxmoxLxc } from './lxc.ts';
|
|
20
|
+
import type { LxcProps } from './lxc-props.ts';
|
|
21
|
+
|
|
22
|
+
const NODE = 'pve1';
|
|
23
|
+
const VMID = 100;
|
|
24
|
+
const KEY = `${NODE}/${String(VMID)}`;
|
|
25
|
+
const TAKEOVER = 'already exists, and this stack holds no state for it';
|
|
26
|
+
|
|
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
|
+
let fake: FakePve | undefined;
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
fake?.stop();
|
|
35
|
+
fake = undefined;
|
|
36
|
+
});
|
|
37
|
+
const cluster = () => {
|
|
38
|
+
fake = fakePve();
|
|
39
|
+
return fake;
|
|
40
|
+
};
|
|
41
|
+
const live = () => {
|
|
42
|
+
const pve = cluster();
|
|
43
|
+
seed(pve, NODE, VMID);
|
|
44
|
+
return pve;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('with no state, a live guest is never adopted silently', () => {
|
|
48
|
+
test.each([
|
|
49
|
+
['one that matches', 'ct-example'],
|
|
50
|
+
['one that differs', 'somebody-else'],
|
|
51
|
+
])('%s: the plan fails "Cannot adopt" and nothing is written', async (_, hostname) => {
|
|
52
|
+
const pve = live();
|
|
53
|
+
const run = await lxcEngine(pve).deploy(
|
|
54
|
+
ProxmoxLxc('ct', { hostname, node: NODE, target: TARGET, vmid: VMID }),
|
|
55
|
+
);
|
|
56
|
+
expect(run.failure).toContain('Cannot adopt');
|
|
57
|
+
expect(writesOf(pve)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('--adopt takes a matching guest over with no write; the next plan is noop', async () => {
|
|
61
|
+
const pve = live();
|
|
62
|
+
const stack = lxcEngine(pve);
|
|
63
|
+
const run = await stack.deploy(ProxmoxLxc('ct', pasted()), { adopt: true });
|
|
64
|
+
expect(run).toMatchObject({ actions: { ct: 'adopted' }, failure: '' });
|
|
65
|
+
expect(writesOf(pve)).toEqual([]);
|
|
66
|
+
expect((await stack.plan(ProxmoxLxc('ct', pasted()))).actions).toEqual({ ct: 'noop' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('adopt(false) wins over --adopt', async () => {
|
|
70
|
+
const pve = live();
|
|
71
|
+
const declared = ProxmoxLxc('ct', pasted()).pipe(adopt(false));
|
|
72
|
+
const run = await lxcEngine(pve).deploy(declared, { adopt: true });
|
|
73
|
+
expect(run.failure).toContain('Cannot adopt');
|
|
74
|
+
expect(writesOf(pve)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('a create never takes over a guest it finds at apply', () => {
|
|
79
|
+
test('one that appears after the plan is refused, matching or not, and its row forgotten', async () => {
|
|
80
|
+
const pve = live();
|
|
81
|
+
pve.vanish.set(KEY, 2);
|
|
82
|
+
const stack = lxcEngine(pve);
|
|
83
|
+
expect((await stack.plan(ProxmoxLxc('ct', pasted()))).actions).toEqual({ ct: 'create' });
|
|
84
|
+
const run = await stack.deploy(ProxmoxLxc('ct', pasted()));
|
|
85
|
+
expect(run.failure).toContain(TAKEOVER);
|
|
86
|
+
expect(writesOf(pve)).toEqual([]);
|
|
87
|
+
expect(stack.status('ct')).toBeUndefined();
|
|
88
|
+
expect((await stack.plan(ProxmoxLxc('ct', pasted()))).failure).toContain('Cannot adopt');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('under --adopt, one that matches is recorded with no write', async () => {
|
|
92
|
+
const pve = live();
|
|
93
|
+
pve.vanish.set(KEY, 1);
|
|
94
|
+
const stack = lxcEngine(pve);
|
|
95
|
+
const run = await stack.deploy(ProxmoxLxc('ct', pasted()), { adopt: true });
|
|
96
|
+
expect(run).toMatchObject({ actions: { ct: 'create' }, failure: '' });
|
|
97
|
+
expect(writesOf(pve)).toEqual([]);
|
|
98
|
+
expect((await stack.plan(ProxmoxLxc('ct', pasted()))).actions).toEqual({ ct: 'noop' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('under --adopt, one that differs is still refused until a plan can show it', async () => {
|
|
102
|
+
const pve = live();
|
|
103
|
+
pve.vanish.set(KEY, 2);
|
|
104
|
+
const stack = lxcEngine(pve);
|
|
105
|
+
const declared = () => ProxmoxLxc('ct', pasted({ memory: 1024 }));
|
|
106
|
+
expect((await stack.plan(declared())).actions).toEqual({ ct: 'create' });
|
|
107
|
+
const run = await stack.deploy(declared(), { adopt: true });
|
|
108
|
+
expect(run.failure).toContain('already exists on pve1 and differs in memory');
|
|
109
|
+
expect(writesOf(pve)).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('an interrupted create', () => {
|
|
114
|
+
const door = (over: Partial<LxcProps> = {}) =>
|
|
115
|
+
ProxmoxLxc('door', {
|
|
116
|
+
hostname: 'door-example',
|
|
117
|
+
node: NODE,
|
|
118
|
+
ostemplate: 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst',
|
|
119
|
+
rootfs: 'local-zfs:8',
|
|
120
|
+
target: TARGET,
|
|
121
|
+
vmid: 150,
|
|
122
|
+
...over,
|
|
123
|
+
});
|
|
124
|
+
const interrupted = async () => {
|
|
125
|
+
const pve = cluster();
|
|
126
|
+
const stack = lxcEngine(pve);
|
|
127
|
+
pve.task.exit = 'interrupted';
|
|
128
|
+
expect((await stack.deploy(door())).failure).toContain('ended "interrupted"');
|
|
129
|
+
expect(stack.status('door')).toBe('creating');
|
|
130
|
+
pve.task.exit = 'OK';
|
|
131
|
+
return { pve, stack };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
test('resumes without --adopt when the guest is what it wrote, and writes nothing more', async () => {
|
|
135
|
+
const { pve, stack } = await interrupted();
|
|
136
|
+
expect((await stack.deploy(door())).failure).toBe('');
|
|
137
|
+
expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
|
|
138
|
+
expect((await stack.plan(door())).actions).toEqual({ door: 'noop' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('resumes when the plan reads no guest and the apply then finds it (diff noted it)', async () => {
|
|
142
|
+
const { pve, stack } = await interrupted();
|
|
143
|
+
pve.vanish.set(`${NODE}/150`, 1);
|
|
144
|
+
expect((await stack.deploy(door())).failure).toBe('');
|
|
145
|
+
expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
|
|
146
|
+
expect(stack.status('door')).toBe('created');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('asks for --adopt when the guest changed meanwhile, and resumes with it', async () => {
|
|
150
|
+
const { pve, stack } = await interrupted();
|
|
151
|
+
const guest = pve.guests.get(`${NODE}/150`) ?? {};
|
|
152
|
+
guest['hostname'] = 'someone-else';
|
|
153
|
+
expect((await stack.deploy(door())).failure).toContain('Cannot resume creating');
|
|
154
|
+
const resumed = await stack.deploy(door(), { adopt: true });
|
|
155
|
+
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');
|
|
158
|
+
});
|
|
159
|
+
});
|