@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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a container's config values are spelled on the wire, and when two spellings are one value.
|
|
3
|
+
*
|
|
4
|
+
* ★ EVERY RULE HERE IS READ OFF pve-container 6.1.14 (the PVE 9 line, `src/PVE/LXC/Config.pm` at
|
|
5
|
+
* git.proxmox.com HEAD, 2026-09-21) or off a live guest's config read with
|
|
6
|
+
* `pvesh get /nodes/<node>/lxc/100/config`. A rule nobody measured is a forever-diff waiting to
|
|
7
|
+
* happen: the plan reports an update, the deploy writes the same value back, and the next plan
|
|
8
|
+
* reports it again.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ PVE PRINTS A PROPERTY STRING IN ITS OWN KEY ORDER AND WITH ITS OWN DEFAULTS FILLED IN.
|
|
11
|
+
* MEASURED on a live guest: `net0` comes back `name=eth0,bridge=…,firewall=0,gw=…,hwaddr=…,ip=…,
|
|
12
|
+
* mtu=1500,tag=…,type=veth` — `name` first, then alphabetical, with `type=veth` and a generated
|
|
13
|
+
* `hwaddr` nobody declared — and `mp0` carries `backup=0` although `0` is what absent means.
|
|
14
|
+
* So values are compared as maps with the defaults dropped, never as strings.
|
|
15
|
+
*/
|
|
16
|
+
import { canonicalToken, propertyString } from './values.ts';
|
|
17
|
+
|
|
18
|
+
/** `a=1,b=2` → `[['a','1'],['b','2']]`. A bare token is `defaultKey`'s value. */
|
|
19
|
+
export const pairs = (value: string, defaultKey?: string): [string, string][] =>
|
|
20
|
+
value
|
|
21
|
+
.split(',')
|
|
22
|
+
.map((part) => part.trim())
|
|
23
|
+
.filter((part) => part !== '')
|
|
24
|
+
.map((part): [string, string] => {
|
|
25
|
+
const at = part.indexOf('=');
|
|
26
|
+
if (at === -1) return [defaultKey ?? part, defaultKey === undefined ? '' : part];
|
|
27
|
+
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A key's default, per family: the value PVE treats absence as.
|
|
32
|
+
* ⚠️ `replicate` DEFAULTS ON and everything else here defaults off. `backup` on a mount point is
|
|
33
|
+
* off when absent: vzdump skips any `mpN` without `backup=1` (the rootfs is always included).
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
36
|
+
dev: { 'deny-write': '0' },
|
|
37
|
+
features: { force_rw_sys: '0', fuse: '0', keyctl: '0', mknod: '0', nesting: '0' },
|
|
38
|
+
net: { firewall: '0', 'host-managed': '0', link_down: '0', type: 'veth' },
|
|
39
|
+
volume: { backup: '0', keepattrs: '0', quota: '0', replicate: '1', ro: '0', shared: '0' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Boolean keys, whose `1`/`true`/`on` spellings are one value. */
|
|
43
|
+
const FLAGS = new Set([
|
|
44
|
+
'acl',
|
|
45
|
+
'backup',
|
|
46
|
+
'deny-write',
|
|
47
|
+
'firewall',
|
|
48
|
+
'force_rw_sys',
|
|
49
|
+
'fuse',
|
|
50
|
+
'host-managed',
|
|
51
|
+
'keepattrs',
|
|
52
|
+
'keyctl',
|
|
53
|
+
'link_down',
|
|
54
|
+
'mknod',
|
|
55
|
+
'nesting',
|
|
56
|
+
'quota',
|
|
57
|
+
'replicate',
|
|
58
|
+
'ro',
|
|
59
|
+
'shared',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Keys holding a `;` list PVE does not promise to keep in order. */
|
|
63
|
+
const SETS = new Set(['mount', 'mountoptions']);
|
|
64
|
+
|
|
65
|
+
/** One option value in its comparable spelling. ⚠️ A MAC is upper-cased: PVE stores it that way. */
|
|
66
|
+
const canonicalValue = (key: string, value: string) => {
|
|
67
|
+
if (FLAGS.has(key)) return canonicalToken(value);
|
|
68
|
+
if (SETS.has(key))
|
|
69
|
+
return [...new Set(value.split(';').map((part) => part.trim()))].sort().join(';');
|
|
70
|
+
if (key === 'hwaddr') return value.toUpperCase();
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A property string as a map of comparable values, with `family`'s defaults dropped. */
|
|
75
|
+
export const optionMap = (
|
|
76
|
+
value: string,
|
|
77
|
+
family: keyof typeof DEFAULTS,
|
|
78
|
+
defaultKey?: string,
|
|
79
|
+
): Map<string, string> => {
|
|
80
|
+
const defaults = DEFAULTS[family] ?? {};
|
|
81
|
+
const out = new Map<string, string>();
|
|
82
|
+
for (const [key, raw] of pairs(value, defaultKey)) {
|
|
83
|
+
const canonical = canonicalValue(key, raw);
|
|
84
|
+
if (defaults[key] !== canonical) out.set(key, canonical);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const sameMap = (left: Map<string, string>, right: Map<string, string>) =>
|
|
90
|
+
left.size === right.size && [...left].every(([key, value]) => right.get(key) === value);
|
|
91
|
+
|
|
92
|
+
/** A map as one order-free string, for comparing and for naming a value in a message. */
|
|
93
|
+
export const sortedForm = (map: Map<string, string>) =>
|
|
94
|
+
[...map]
|
|
95
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
96
|
+
.sort()
|
|
97
|
+
.join(',');
|
|
98
|
+
|
|
99
|
+
/** A map back to a property string, `lead` keys first — PVE wants `name` and the volume first. */
|
|
100
|
+
export const printMap = (map: Map<string, string>, lead: readonly string[] = []) =>
|
|
101
|
+
[...lead.filter((key) => map.has(key)), ...[...map.keys()].filter((k) => !lead.includes(k))]
|
|
102
|
+
.map((key) => `${key}=${map.get(key) ?? ''}`)
|
|
103
|
+
.join(',');
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `net` maps for comparison. ⚠️ AN UNDECLARED `hwaddr` IS PVE'S, NOT DRIFT: PVE generates one on
|
|
107
|
+
* create, so the live MAC is dropped before comparing when the declaration names none.
|
|
108
|
+
*/
|
|
109
|
+
export const netMaps = (declared: string, live: string) => {
|
|
110
|
+
const want = optionMap(declared, 'net');
|
|
111
|
+
const have = optionMap(live, 'net');
|
|
112
|
+
if (!want.has('hwaddr')) have.delete('hwaddr');
|
|
113
|
+
return { have, want };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `net` value to PUT: the declaration, plus the live MAC when it names none.
|
|
118
|
+
* ⛔ WITHOUT THE LIVE MAC, A PUT OF AN UNCHANGED NIC WOULD GENERATE A NEW ONE — a new MAC is a new
|
|
119
|
+
* DHCP lease and a new IPv6 link-local address for a guest that only had its MTU edited.
|
|
120
|
+
*/
|
|
121
|
+
export const netWrite = (declared: string, live: string | undefined) => {
|
|
122
|
+
const want = new Map(pairs(declared));
|
|
123
|
+
const mac = live === undefined ? undefined : new Map(pairs(live)).get('hwaddr');
|
|
124
|
+
if (!want.has('hwaddr') && mac !== undefined) want.set('hwaddr', mac);
|
|
125
|
+
return printMap(want, ['name']);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Tags as PVE stores them: split on `;`, `,` or space, deduplicated, sorted, lower-cased. */
|
|
129
|
+
export const tagSet = (value: string) =>
|
|
130
|
+
[...new Set(value.split(/[;,\s]+/).filter((tag) => tag !== ''))]
|
|
131
|
+
.map((tag) => tag.toLowerCase())
|
|
132
|
+
.sort()
|
|
133
|
+
.join(';');
|
|
134
|
+
|
|
135
|
+
/** A resolver list: order matters, the separator does not. */
|
|
136
|
+
export const orderedList = (value: string) =>
|
|
137
|
+
value
|
|
138
|
+
.split(/[;,\s]+/)
|
|
139
|
+
.filter((part) => part !== '')
|
|
140
|
+
.join(' ');
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ⚠️ PVE STORES A DESCRIPTION AS `#` COMMENT LINES AND READS EACH BACK WITH `"\n"` APPENDED
|
|
144
|
+
* (`parse_pct_config`), so `foo` comes back `foo\n`. Trailing whitespace is not the value.
|
|
145
|
+
*/
|
|
146
|
+
export const descriptionText = (value: string) => value.replace(/\s+$/, '');
|
|
147
|
+
|
|
148
|
+
/** How each scalar config key is compared. A key missing here is not one this resource manages. */
|
|
149
|
+
export const SCALAR_KINDS: Readonly<Record<string, 'text' | 'number' | 'flag' | 'form'>> = {
|
|
150
|
+
arch: 'text',
|
|
151
|
+
cmode: 'text',
|
|
152
|
+
console: 'flag',
|
|
153
|
+
cores: 'number',
|
|
154
|
+
cpulimit: 'number',
|
|
155
|
+
cpuunits: 'number',
|
|
156
|
+
description: 'form',
|
|
157
|
+
features: 'form',
|
|
158
|
+
hostname: 'text',
|
|
159
|
+
memory: 'number',
|
|
160
|
+
nameserver: 'form',
|
|
161
|
+
onboot: 'flag',
|
|
162
|
+
ostype: 'text',
|
|
163
|
+
protection: 'flag',
|
|
164
|
+
searchdomain: 'form',
|
|
165
|
+
startup: 'form',
|
|
166
|
+
swap: 'number',
|
|
167
|
+
tags: 'form',
|
|
168
|
+
timezone: 'text',
|
|
169
|
+
tty: 'number',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** Keys a declaration may set to `''` to mean "remove it". `delete=` takes them; nothing else may. */
|
|
173
|
+
export const CLEARABLE: ReadonlySet<string> = new Set([
|
|
174
|
+
'description',
|
|
175
|
+
'features',
|
|
176
|
+
'nameserver',
|
|
177
|
+
'searchdomain',
|
|
178
|
+
'startup',
|
|
179
|
+
'tags',
|
|
180
|
+
'timezone',
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What an absent key means, from the confdesc defaults. ⚠️ Only these compare equal to absence;
|
|
185
|
+
* `cores` absent means "every host core", which no number declares.
|
|
186
|
+
*/
|
|
187
|
+
export const SCALAR_DEFAULTS: Readonly<Record<string, string>> = {
|
|
188
|
+
arch: 'amd64',
|
|
189
|
+
cmode: 'tty',
|
|
190
|
+
console: '1',
|
|
191
|
+
cpulimit: '0',
|
|
192
|
+
memory: '512',
|
|
193
|
+
onboot: '0',
|
|
194
|
+
protection: '0',
|
|
195
|
+
swap: '512',
|
|
196
|
+
tty: '2',
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** One scalar value in its comparable spelling. */
|
|
200
|
+
export const scalarForm = (key: string, value: string): string => {
|
|
201
|
+
const kind = SCALAR_KINDS[key];
|
|
202
|
+
if (kind === 'number') return String(Number(value));
|
|
203
|
+
if (kind === 'flag') return canonicalToken(value);
|
|
204
|
+
if (key === 'description') return descriptionText(value);
|
|
205
|
+
if (key === 'features') return sortedForm(optionMap(value, 'features'));
|
|
206
|
+
if (key === 'nameserver' || key === 'searchdomain') return orderedList(value);
|
|
207
|
+
if (key === 'startup') return propertyString(value, 'order');
|
|
208
|
+
if (key === 'tags') return tagSet(value);
|
|
209
|
+
return value;
|
|
210
|
+
};
|
package/src/proxmox/lxc.ts
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.Lxc` — a container, declared
|
|
2
|
+
* `Proxmox.Lxc` — a container, declared: adopted as it runs, or created from a template.
|
|
3
3
|
*
|
|
4
|
-
* ★ `hf-provision@pve` HOLDS EXACTLY THE ROLE THIS NEEDS
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ★ `hf-provision@pve` HOLDS EXACTLY THE ROLE THIS NEEDS: `LXCProvisioner` on `/` grants
|
|
5
|
+
* VM.Allocate, VM.Audit, VM.Config.{CPU,Disk,HWType,Memory,Network,Options}, VM.PowerMgmt,
|
|
6
|
+
* Datastore.AllocateSpace, Datastore.Audit, SDN.Use, Sys.Audit. What that role cannot do — device
|
|
7
|
+
* passthrough, bind mounts, features beyond nesting — PVE keeps for root@pam, and lxc-judge.ts
|
|
8
|
+
* refuses at plan with the `pct set` to run instead.
|
|
8
9
|
*
|
|
9
|
-
* ⛔
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* ⛔ NOTHING HERE PLANS A REPLACE, AND NOTHING DESTROYS A GUEST BY DEFAULT. A replace of a guest is
|
|
11
|
+
* a delete of a running machine and its disks, so every change PVE cannot make in place — a new
|
|
12
|
+
* vmid, another template, an unprivileged flip, a storage move, a smaller disk, a move to
|
|
13
|
+
* another node — FAILS the plan with a sentence instead. `defaultRemovalPolicy: 'retain'`:
|
|
14
|
+
* dropping the declaration drops the state row and leaves the guest running; only
|
|
15
|
+
* `RemovalPolicy.destroy()` reaches `destroyGuest`, which deletes only a guest that still
|
|
16
|
+
* matches its last declaration, and PVE still refuses a running or protected one.
|
|
17
|
+
* ⛔ AND NOTHING HERE WRITES A VOLUME IT DID NOT ALLOCATE. A create takes `storage:GiB` only
|
|
18
|
+
* (lxc-create-form.ts), and a deploy that planned a create never writes onto a guest it finds.
|
|
13
19
|
*
|
|
14
|
-
* ⚠️
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
20
|
+
* ⚠️ AN ADOPTION ALWAYS PLANS `adopted`, EVEN WHEN IT WOULD WRITE. alchemy 2.0.0-beta.79 turns a
|
|
21
|
+
* cold adoption's diff into an update whatever it answered (Plan.ts, `forceUpdateAfterAdoption`)
|
|
22
|
+
* and prints no property diff for it. So `diff` LOGS the keys a deploy would write, by name
|
|
23
|
+
* (never by value), as a warning — the only place a plan can say the declaration is not the live
|
|
24
|
+
* config yet. A clean adoption logs nothing, and its deploy writes nothing (lxc-judge.ts).
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ POWER STATE IS NOT DECLARED. `onboot` is config; start and stop are an operator's act, as for
|
|
27
|
+
* `Proxmox.Vm`. `start: 1` starts a guest once, after the create that built it, and never again.
|
|
18
28
|
*/
|
|
19
29
|
import { Resource } from 'alchemy';
|
|
30
|
+
import { isResolved } from 'alchemy/Diff';
|
|
31
|
+
import type { Input } from 'alchemy/Input';
|
|
20
32
|
import * as Provider from 'alchemy/Provider';
|
|
21
33
|
import * as Effect from 'effect/Effect';
|
|
22
|
-
import { type
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/** Start on boot. Mutable. */
|
|
41
|
-
onboot?: boolean;
|
|
42
|
-
}
|
|
34
|
+
import { type Owner, refuseTakeover } from '../ownership/adopt.ts';
|
|
35
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
36
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
37
|
+
import { createRefusals } from './lxc-create-form.ts';
|
|
38
|
+
import { identityRefusals } from './lxc-identity.ts';
|
|
39
|
+
import { judge } from './lxc-judge.ts';
|
|
40
|
+
import {
|
|
41
|
+
LxcRefusedError,
|
|
42
|
+
type LxcWhere,
|
|
43
|
+
attributesOf,
|
|
44
|
+
createGuest,
|
|
45
|
+
destroyGuest,
|
|
46
|
+
readLive,
|
|
47
|
+
updateGuest,
|
|
48
|
+
} from './lxc-lifecycle.ts';
|
|
49
|
+
import type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
50
|
+
import type { PveRequirements } from './resource.ts';
|
|
51
|
+
import { text } from './values.ts';
|
|
43
52
|
|
|
44
|
-
export
|
|
45
|
-
vmid: number;
|
|
46
|
-
node: string;
|
|
47
|
-
hostname: string;
|
|
48
|
-
memory: number;
|
|
49
|
-
cores: number;
|
|
50
|
-
onboot: boolean;
|
|
51
|
-
/** `running` | `stopped`. Reported, never declared — power state is not configuration. */
|
|
52
|
-
status: string;
|
|
53
|
-
}
|
|
53
|
+
export type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
54
54
|
|
|
55
55
|
export interface ProxmoxLxc extends Resource<
|
|
56
56
|
'Proxmox.Lxc',
|
|
@@ -60,58 +60,181 @@ export interface ProxmoxLxc extends Resource<
|
|
|
60
60
|
PveRequirements
|
|
61
61
|
> {}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/** ★ `retain` by default — a guest's disks cannot be rebuilt from a line. See resource.ts. */
|
|
64
|
+
export const ProxmoxLxc = Resource<ProxmoxLxc>('Proxmox.Lxc', { defaultRemovalPolicy: 'retain' });
|
|
65
|
+
|
|
66
|
+
/** The ownership fields of one call (ownership/adopt.ts), with this resource's attributes. */
|
|
67
|
+
type LxcOwner = Owner & { readonly output: LxcAttributes | undefined };
|
|
68
|
+
|
|
69
|
+
const refused = (reasons: readonly string[]) =>
|
|
70
|
+
Effect.fail(new LxcRefusedError(reasons.join('\n')));
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
72
|
+
/**
|
|
73
|
+
* ★ A ROW WITH NO ATTRIBUTES IS AN UNFINISHED CREATE. provingResumes (ownership/resume.ts) asks
|
|
74
|
+
* `read` whether the guest there is that create's before this runs, and notes it for the apply
|
|
75
|
+
* that follows, so reconcile can tell our own interrupted create from a guest somebody else put
|
|
76
|
+
* at this vmid.
|
|
77
|
+
*/
|
|
78
|
+
const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | undefined) =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const identity = identityRefusals(news, olds, output);
|
|
81
|
+
if (identity.length > 0) return yield* refused(identity);
|
|
82
|
+
if (output === undefined) return undefined;
|
|
83
|
+
if (!isResolved(news)) return undefined;
|
|
84
|
+
const live = yield* readLive(news);
|
|
85
|
+
// ⚠️ `update`, not `create`: state exists and the guest does not. reconcile rebuilds it from
|
|
86
|
+
// `ostemplate`, or refuses when there is none — an adopted guest has no recipe.
|
|
87
|
+
// ⛔ AND THE PLAN SAYS SO. A bare `update` here read as an edit while the deploy would build a
|
|
88
|
+
// NEW guest with empty volumes; an uncreatable one now fails at plan rather than at deploy.
|
|
89
|
+
if (live === undefined) {
|
|
90
|
+
const refusals = createRefusals(news);
|
|
91
|
+
if (refusals.length > 0) return yield* refused(refusals);
|
|
92
|
+
yield* Effect.logWarning(
|
|
93
|
+
`Proxmox.Lxc ${news.node}/${String(news.vmid)}: the cluster lists no guest with this ` +
|
|
94
|
+
'vmid -- a deploy CREATES it again from ostemplate, with new, empty volumes.',
|
|
95
|
+
);
|
|
96
|
+
return { action: 'update' } as const;
|
|
97
|
+
}
|
|
98
|
+
const change = judge(news, live);
|
|
99
|
+
if (change.refuse.length > 0) return yield* refused(change.refuse);
|
|
100
|
+
const moved = news.node !== output.node;
|
|
101
|
+
if (change.drift.length === 0 && !moved) return { action: 'noop' } as const;
|
|
102
|
+
const what =
|
|
103
|
+
change.drift.length === 0
|
|
104
|
+
? `the guest is on ${news.node} now, not ${output.node} -- a deploy re-records it, ` +
|
|
105
|
+
'writing nothing.'
|
|
106
|
+
: `live config differs from the declaration in ${change.drift.join(', ')} -- a ` +
|
|
107
|
+
'deploy writes these.';
|
|
108
|
+
yield* Effect.logWarning(`Proxmox.Lxc ${news.node}/${String(news.vmid)}: ${what}`);
|
|
109
|
+
return { action: 'update' } as const;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ⛔ THE SAME JUDGE AS `diff`, RE-RUN AGAINST A FRESH READ, AND AN EMPTY CHANGE WRITES NOTHING.
|
|
114
|
+
* 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.
|
|
116
|
+
* ⛔ AND IT READS BACK AND JUDGES AGAIN. PVE answers 200 on writes that did nothing, so a change
|
|
117
|
+
* that did not land is a failure here, not a state row claiming it did. ⚠️ A change PVE parked
|
|
118
|
+
* as pending on a running guest DOES read back (GET config merges `[pending]` unless asked for
|
|
119
|
+
* `current`), so it passes here and takes effect at the guest's next restart.
|
|
120
|
+
* ⛔ A CREATE NEVER WRITES ONTO A GUEST THAT IS ALREADY THERE. `output` undefined means the plan
|
|
121
|
+
* said `create`: its adoption probe saw no guest, or never ran (Plan.ts skips it while any prop
|
|
122
|
+
* is an unresolved Output — a `net0` built from a vnet created in the same deploy). Finding one
|
|
123
|
+
* now, it is first an OWNERSHIP question (docs/ownership.md): `refuseTakeover` lets it through
|
|
124
|
+
* only for this resource's own interrupted create or under `--adopt` / `adopt(true)`, and
|
|
125
|
+
* otherwise refuses and forgets the `creating` row, a matching guest included. Then a CONTENT
|
|
126
|
+
* one: a guest that already matches is recorded with no write; any other refuses even under
|
|
127
|
+
* `--adopt`, until a plan can show the keys a deploy would write onto it.
|
|
128
|
+
*/
|
|
129
|
+
const reconcile = (owner: LxcOwner, news: LxcProps) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const { output } = owner;
|
|
132
|
+
const identity = identityRefusals(news, news, output);
|
|
133
|
+
if (identity.length > 0) return yield* refused(identity);
|
|
134
|
+
const live = yield* readLive(news);
|
|
135
|
+
if (live === undefined) {
|
|
136
|
+
const refusals = createRefusals(news);
|
|
137
|
+
if (refusals.length > 0) return yield* refused(refusals);
|
|
138
|
+
yield* createGuest(news);
|
|
139
|
+
} else {
|
|
140
|
+
if (output === undefined) {
|
|
141
|
+
yield* refuseTakeover(owner, `Proxmox.Lxc CT ${String(news.vmid)} on ${news.node}`);
|
|
142
|
+
}
|
|
143
|
+
const change = judge(news, live);
|
|
144
|
+
if (output === undefined && change.drift.length > 0) {
|
|
145
|
+
return yield* refused([
|
|
146
|
+
`CT ${String(news.vmid)} already exists on ${news.node} and differs in ` +
|
|
147
|
+
`${change.drift.join(', ')}, but this deploy planned a create. A create never writes ` +
|
|
148
|
+
'onto an existing guest: plan again, so the plan can show it and adopt it.',
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
if (change.refuse.length > 0) return yield* refused(change.refuse);
|
|
152
|
+
if (change.drift.length > 0) yield* updateGuest(news, change, text(live['digest']));
|
|
153
|
+
}
|
|
154
|
+
const after = yield* readLive(news);
|
|
155
|
+
if (after === undefined) {
|
|
156
|
+
return yield* refused([
|
|
157
|
+
`CT ${String(news.vmid)}: the write returned no error but the guest is still absent.`,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
const left = judge(news, after).drift;
|
|
161
|
+
if (left.length > 0) {
|
|
162
|
+
return yield* refused([
|
|
163
|
+
`CT ${String(news.vmid)}: after the write, ${left.join(', ')} still differ from the ` +
|
|
164
|
+
'declaration. PVE may have normalised the value (tags are lower-cased by default) -- ' +
|
|
165
|
+
'declare what it stores.',
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
return attributesOf(news, after);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const whereOf = (olds: LxcProps, output: LxcAttributes | undefined): LxcWhere => ({
|
|
172
|
+
node: output?.node ?? olds.node,
|
|
173
|
+
target: olds.target,
|
|
174
|
+
vmid: output?.vmid ?? olds.vmid,
|
|
109
175
|
});
|
|
110
176
|
|
|
111
177
|
/**
|
|
112
|
-
* ⛔
|
|
113
|
-
*
|
|
114
|
-
*
|
|
178
|
+
* ⛔ NO LIVE GUEST IS ADOPTED WITHOUT `--adopt`, NOT EVEN ONE THAT MATCHES (docs/ownership.md,
|
|
179
|
+
* decided 2026-09-21). With no state this read is Alchemy's adoption probe (Plan.ts), and plain
|
|
180
|
+
* attributes would mean "ours", adopted silently. Matching is not proof: a pasted config or a
|
|
181
|
+
* mistyped vmid reads the same as our own guest, and once state claims it,
|
|
182
|
+
* `RemovalPolicy.destroy()` deletes it and its volumes from its real owner. So it reads
|
|
183
|
+
* `Unowned`, and the plan fails "Cannot adopt" until `adopt(true)` or `--adopt` says so.
|
|
184
|
+
* ★ THE RECOVERY READ STILL FINISHES OUR OWN CREATE. A `creating` row whose deploy died after the
|
|
185
|
+
* POST carries its own instance id; `ownedRead` accepts the guest when the state store records
|
|
186
|
+
* that id AND the guest still matches the row's props — the same judge `diff` uses.
|
|
187
|
+
* ⚠️ UNLIKE `CaddyConfig`, which keeps adopting an identical config because its delete never
|
|
188
|
+
* touches Caddy: a container's delete removes a machine and its disks.
|
|
115
189
|
*/
|
|
190
|
+
const read = (owner: LxcOwner, olds: LxcProps) =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
const where = whereOf(olds, owner.output);
|
|
193
|
+
const live = yield* readLive(where);
|
|
194
|
+
if (live === undefined) return undefined;
|
|
195
|
+
const settled = Effect.sync(() => judge(olds, live).drift.length === 0);
|
|
196
|
+
return yield* ownedRead(owner, attributesOf(where, live), settled);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* ⛔ HAND-WRITTEN RATHER THAN `pveHandlers`: a guest reads failures as failures, grows disks
|
|
201
|
+
* through a second endpoint, waits on tasks and refuses what the factory would replace. `list`
|
|
202
|
+
* is empty for the reason it is everywhere here: `GET /nodes/{n}/lxc` lists every guest on the
|
|
203
|
+
* node, and adopting one is a declaration, never a side effect of registering the provider.
|
|
204
|
+
*/
|
|
205
|
+
type Asked = { readonly fqn: string; readonly instanceId: string };
|
|
206
|
+
|
|
207
|
+
const handlers = {
|
|
208
|
+
delete: ({ olds, output }: { olds: LxcProps; output: LxcAttributes }) =>
|
|
209
|
+
destroyGuest(whereOf(olds, output), olds),
|
|
210
|
+
diff: ({
|
|
211
|
+
news,
|
|
212
|
+
olds,
|
|
213
|
+
output,
|
|
214
|
+
}: {
|
|
215
|
+
news: Input<LxcProps>;
|
|
216
|
+
olds: LxcProps;
|
|
217
|
+
output: LxcAttributes | undefined;
|
|
218
|
+
}) => diff(news, olds, output),
|
|
219
|
+
list: () => Effect.succeed([]),
|
|
220
|
+
read: ({
|
|
221
|
+
fqn,
|
|
222
|
+
instanceId,
|
|
223
|
+
olds,
|
|
224
|
+
output,
|
|
225
|
+
}: Asked & { olds: LxcProps; output: LxcAttributes | undefined }) =>
|
|
226
|
+
read({ fqn, instanceId, output }, olds),
|
|
227
|
+
reconcile: ({
|
|
228
|
+
fqn,
|
|
229
|
+
instanceId,
|
|
230
|
+
news,
|
|
231
|
+
output,
|
|
232
|
+
}: Asked & { news: LxcProps; output: LxcAttributes | undefined }) =>
|
|
233
|
+
reconcile({ fqn, instanceId, output }, news),
|
|
234
|
+
};
|
|
235
|
+
|
|
116
236
|
export const ProxmoxLxcProvider = () =>
|
|
117
|
-
Provider.effect(
|
|
237
|
+
Provider.effect(
|
|
238
|
+
ProxmoxLxc,
|
|
239
|
+
Effect.succeed(ProxmoxLxc.Provider.of(handlers)).pipe(Effect.map(provingResumes)),
|
|
240
|
+
);
|
|
@@ -194,6 +194,8 @@ export const awaitTask = (
|
|
|
194
194
|
credential: PveCredential,
|
|
195
195
|
node: string,
|
|
196
196
|
upid: string,
|
|
197
|
+
// ★ A CALLER WHOSE TASK IS SLOWER THAN A RELOAD SAYS SO: an LXC create unpacks a template.
|
|
198
|
+
attempts: number = ATTEMPTS,
|
|
197
199
|
) =>
|
|
198
200
|
Effect.gen(function* () {
|
|
199
201
|
/**
|
|
@@ -206,7 +208,7 @@ export const awaitTask = (
|
|
|
206
208
|
* and a perfectly good reload would stop the deploy. Raw removes the assumption.
|
|
207
209
|
*/
|
|
208
210
|
const path = `nodes/${node}/tasks/${upid}/status`;
|
|
209
|
-
for (let attempt = 0; attempt <
|
|
211
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
210
212
|
/**
|
|
211
213
|
* ⛔ A FAILED POLL IS NOT A FAILED RELOAD, AND CALLING IT ONE WOULD BE THE WORST KIND OF
|
|
212
214
|
* WRONG. If the member serving the poll is the node being reloaded, `ifreload -a` can drop
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
} from './pbs-datastore-form.ts';
|
|
66
66
|
import { guardBackend, guardPath, settle } from './pbs-datastore-guard.ts';
|
|
67
67
|
import type { PveRequirements } from './resource.ts';
|
|
68
|
+
import { formToSend } from './update-guard.ts';
|
|
68
69
|
|
|
69
70
|
export type { PbsDatastoreAttributes, PbsTarget };
|
|
70
71
|
|
|
@@ -210,9 +211,9 @@ export const PbsDatastoreProvider = () =>
|
|
|
210
211
|
} else {
|
|
211
212
|
yield* guardPath(live, news);
|
|
212
213
|
yield* guardBackend(live, news);
|
|
213
|
-
// ⚠️ An empty form is not a write
|
|
214
|
-
const form = updateForm(news);
|
|
215
|
-
if (
|
|
214
|
+
// ⚠️ An empty form is not a write, and a match is not one either — update-guard.ts.
|
|
215
|
+
const form = formToSend(matches, live, news, updateForm(news));
|
|
216
|
+
if (form !== undefined) {
|
|
216
217
|
yield* pve(news.target, 'provision', 'PUT', object(news), form);
|
|
217
218
|
}
|
|
218
219
|
}
|
package/src/proxmox/resource.ts
CHANGED
|
@@ -22,6 +22,7 @@ import * as Effect from 'effect/Effect';
|
|
|
22
22
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
23
|
import { pve } from './client.ts';
|
|
24
24
|
import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
|
|
25
|
+
import { formToSend } from './update-guard.ts';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* ★ WHY SEVERAL FAMILIES HERE DECLARE `defaultRemovalPolicy: 'retain'`, WRITTEN ONCE.
|
|
@@ -150,7 +151,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
|
150
151
|
spec.collection(news),
|
|
151
152
|
spec.createForm(news),
|
|
152
153
|
);
|
|
153
|
-
} else if (spec.updateForm !== undefined
|
|
154
|
+
} else if (spec.updateForm !== undefined) {
|
|
154
155
|
/**
|
|
155
156
|
* ⛔ ADOPTING AN OBJECT THAT ALREADY MATCHES MUST NOT WRITE TO IT, AND WITHOUT THE
|
|
156
157
|
* `matches` GUARD IT DID. Alchemy's `adopted` action is NOT a read: Apply.ts routes it
|
|
@@ -171,13 +172,12 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
|
171
172
|
* A field deliberately left OUT of `matches` is one this resource does not manage, so
|
|
172
173
|
* its drift is not this provider's to repair.
|
|
173
174
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* broken provider at worst.
|
|
175
|
+
* ★ THE PREDICATE LIVES IN update-guard.ts NOW, with the ⚠️ on why an empty form is not a
|
|
176
|
+
* write either, because CephPool and PbsDatastore write their own reconcile and need
|
|
177
|
+
* the same one — CephPool did not have it (see that file).
|
|
178
178
|
*/
|
|
179
|
-
const form = spec.updateForm(news);
|
|
180
|
-
if (
|
|
179
|
+
const form = formToSend(spec.matches, live, news, spec.updateForm(news));
|
|
180
|
+
if (form !== undefined) {
|
|
181
181
|
yield* pve(news.target, 'provision', 'PUT', spec.path(news), form);
|
|
182
182
|
}
|
|
183
183
|
}
|