@homeflare/alchemy 0.10.1 → 0.12.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 +3 -3
- package/dist/caddy/admin-guard.d.ts +1 -1
- package/dist/caddy/index.js.map +4 -4
- package/dist/cloudflare/client.d.ts +1 -1
- package/dist/cloudflare/index.js.map +2 -2
- package/dist/cloudflare/lock-rules.d.ts +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +1 -1
- package/dist/forgejo/branch-protection.d.ts +1 -1
- package/dist/forgejo/index.js.map +10 -10
- package/dist/forgejo/org-actions-secrets.d.ts +1 -1
- package/dist/forgejo/org-label.d.ts +1 -1
- package/dist/forgejo/org-team.d.ts +1 -1
- package/dist/forgejo/repo-webhook.d.ts +1 -1
- package/dist/forgejo/repository.d.ts +2 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +2 -2
- package/dist/forgejo/team-member.d.ts +1 -1
- package/dist/launchd/index.js.map +2 -2
- package/dist/openbao/auth-role.d.ts +1 -1
- package/dist/openbao/bao-http.d.ts +4 -4
- package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
- package/dist/openbao/cloudflare-role-form.d.ts +2 -2
- package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
- package/dist/openbao/digest.d.ts +1 -1
- package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
- package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
- package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
- package/dist/openbao/index.js +10 -8
- package/dist/openbao/index.js.map +18 -18
- package/dist/openbao/mount.d.ts +1 -1
- package/dist/openbao/pki-role.d.ts +1 -1
- package/dist/openbao/policy.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts +10 -10
- package/dist/openbao/ssh-role.d.ts +3 -3
- package/dist/ownership/adopting.d.ts +34 -0
- package/dist/ownership/adopting.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +15 -5
- package/dist/ownership/probe.d.ts.map +1 -1
- package/dist/ownership/rows.d.ts +12 -0
- package/dist/ownership/rows.d.ts.map +1 -1
- package/dist/ownership/store-fixture.d.ts +10 -0
- package/dist/ownership/store-fixture.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts +2 -2
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +2 -2
- package/dist/proxmox/api-token.d.ts +11 -11
- package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
- package/dist/proxmox/ceph-daemon.d.ts +19 -19
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-flag.d.ts +5 -5
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
- package/dist/proxmox/ceph-fs.d.ts +16 -16
- package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
- package/dist/proxmox/ceph-osd.d.ts +5 -5
- package/dist/proxmox/ceph-pool-form.d.ts +13 -13
- package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool.d.ts +3 -3
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/credentials.d.ts +8 -8
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-form.d.ts +1 -1
- package/dist/proxmox/firewall-alias.d.ts +1 -1
- package/dist/proxmox/generated/pbs.d.ts +1 -1
- package/dist/proxmox/generated/pve.d.ts +1 -1
- package/dist/proxmox/group.d.ts +8 -8
- package/dist/proxmox/ha-resource.d.ts +3 -2
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule-form.d.ts +5 -5
- package/dist/proxmox/ha-rule.d.ts +3 -3
- package/dist/proxmox/index.d.ts +6 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +465 -217
- package/dist/proxmox/index.js.map +63 -58
- package/dist/proxmox/lease-cache.d.ts +4 -4
- package/dist/proxmox/lxc-adoption.d.ts +32 -0
- package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +12 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -1
- package/dist/proxmox/lxc-props.d.ts +2 -2
- package/dist/proxmox/lxc.d.ts +8 -8
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/members.d.ts +3 -3
- package/dist/proxmox/network-apply-read.d.ts +5 -5
- package/dist/proxmox/network-apply.d.ts +9 -9
- package/dist/proxmox/node-network-form.d.ts +6 -6
- package/dist/proxmox/node-network.d.ts +17 -17
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/notification-target.d.ts +3 -2
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
- package/dist/proxmox/pbs-datastore.d.ts +10 -10
- package/dist/proxmox/pbs-prune-job.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job.d.ts +4 -4
- package/dist/proxmox/pbs-verify-job.d.ts +1 -1
- package/dist/proxmox/pool.d.ts +4 -4
- package/dist/proxmox/provision-baseline.d.ts +74 -0
- package/dist/proxmox/provision-baseline.d.ts.map +1 -0
- package/dist/proxmox/provision-bootstrap.d.ts +33 -0
- package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
- package/dist/proxmox/provision-cli-fake.d.ts +38 -0
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
- package/dist/proxmox/provision-declare.d.ts +25 -0
- package/dist/proxmox/provision-declare.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +9 -8
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +2 -2
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +7 -7
- package/dist/proxmox/sdn-apply.d.ts +1 -1
- package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
- package/dist/proxmox/sdn-subnet.d.ts +5 -5
- package/dist/proxmox/sdn-vnet.d.ts +5 -6
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +4 -3
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +3 -2
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/values.d.ts +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +1 -1
- package/dist/proxmox/zfs-pool.d.ts +5 -5
- package/dist/talos/credentials.d.ts +1 -1
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/index.js.map +1 -1
- package/docs/adopt-verify.md +21 -5
- package/docs/adopted-deploys.md +5 -3
- package/docs/ownership.md +27 -2
- package/docs/provision-baseline.md +106 -0
- package/docs/proxmox-lxc-adopt.md +83 -0
- package/docs/proxmox-lxc.md +12 -49
- package/docs/proxmox.md +8 -0
- package/package.json +1 -1
- package/src/caddy/admin-guard.ts +1 -1
- package/src/caddy/local-admin.ts +1 -1
- package/src/cloudflare/client.ts +1 -1
- package/src/cloudflare/lock-rules.ts +1 -1
- package/src/forgejo/branch-protection-form.ts +1 -1
- package/src/forgejo/branch-protection.ts +1 -1
- package/src/forgejo/client.ts +1 -1
- package/src/forgejo/org-actions-secrets.ts +1 -1
- package/src/forgejo/org-label.ts +1 -1
- package/src/forgejo/org-team.ts +1 -1
- package/src/forgejo/repo-webhook.ts +1 -1
- package/src/forgejo/repository.ts +2 -2
- package/src/forgejo/resource.ts +2 -2
- package/src/forgejo/team-member.ts +1 -1
- package/src/openbao/README.md +4 -4
- package/src/openbao/auth-role.ts +1 -1
- package/src/openbao/bao-http.test.ts +2 -2
- package/src/openbao/bao-http.ts +6 -6
- package/src/openbao/bao-status.test.ts +1 -1
- package/src/openbao/bao-status.ts +1 -1
- package/src/openbao/cloudflare-group-scope.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.ts +3 -3
- package/src/openbao/cloudflare-policy.ts +1 -1
- package/src/openbao/cloudflare-role-form.ts +2 -2
- package/src/openbao/cloudflare-roles-expand.ts +1 -1
- package/src/openbao/digest.ts +1 -1
- package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
- package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
- package/src/openbao/forgejo-bootstrap.ts +4 -4
- package/src/openbao/kubernetes-role-form.test.ts +5 -5
- package/src/openbao/mount.ts +1 -1
- package/src/openbao/pki-role.ts +1 -1
- package/src/openbao/policy-wire.test.ts +3 -3
- package/src/openbao/policy.ts +2 -2
- package/src/openbao/proxmox-role-form.ts +5 -5
- package/src/openbao/proxmox-role.ts +11 -11
- package/src/openbao/ssh-role.ts +3 -3
- package/src/ownership/adopting.test.ts +63 -0
- package/src/ownership/adopting.ts +46 -0
- package/src/ownership/ownership.test.ts +1 -34
- package/src/ownership/probe.ts +27 -14
- package/src/ownership/rows.ts +8 -2
- package/src/ownership/store-fixture.ts +50 -0
- package/src/proxmox/acl.ts +4 -4
- package/src/proxmox/adopt-noop.test.ts +6 -6
- package/src/proxmox/api-token-form.ts +4 -4
- package/src/proxmox/api-token.ts +13 -13
- package/src/proxmox/ceph-daemon-form.ts +3 -3
- package/src/proxmox/ceph-daemon.ts +20 -20
- package/src/proxmox/ceph-flag.ts +7 -7
- package/src/proxmox/ceph-fs-wire.ts +7 -7
- package/src/proxmox/ceph-fs.ts +18 -18
- package/src/proxmox/ceph-osd-tree.ts +16 -16
- package/src/proxmox/ceph-osd.ts +6 -6
- package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
- package/src/proxmox/ceph-pool-form.ts +13 -13
- package/src/proxmox/ceph-pool.ts +6 -6
- package/src/proxmox/client.test.ts +3 -3
- package/src/proxmox/client.ts +3 -3
- package/src/proxmox/credentials.test.ts +6 -8
- package/src/proxmox/credentials.ts +8 -8
- package/src/proxmox/fake-pve-lxc.ts +13 -1
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias.ts +1 -1
- package/src/proxmox/generated/pbs.ts +1 -1
- package/src/proxmox/generated/pve.ts +1 -1
- package/src/proxmox/group.ts +10 -10
- package/src/proxmox/ha-resource.ts +3 -2
- package/src/proxmox/ha-rule-form.ts +5 -5
- package/src/proxmox/ha-rule.ts +3 -3
- package/src/proxmox/index.test.ts +7 -0
- package/src/proxmox/index.ts +14 -0
- package/src/proxmox/lease-cache.test.ts +19 -19
- package/src/proxmox/lease-cache.ts +4 -4
- package/src/proxmox/lxc-adopt.test.ts +16 -23
- package/src/proxmox/lxc-adoption.ts +50 -0
- package/src/proxmox/lxc-create-form.ts +1 -1
- package/src/proxmox/lxc-guards.test.ts +10 -17
- package/src/proxmox/lxc-harness.ts +27 -4
- package/src/proxmox/lxc-judge.test.ts +5 -5
- package/src/proxmox/lxc-lifecycle.ts +1 -1
- package/src/proxmox/lxc-ownership.test.ts +19 -14
- package/src/proxmox/lxc-props.ts +2 -2
- package/src/proxmox/lxc-read.test.ts +3 -3
- package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
- package/src/proxmox/lxc-volume.test.ts +9 -9
- package/src/proxmox/lxc-volume.ts +1 -1
- package/src/proxmox/lxc-wire.ts +1 -1
- package/src/proxmox/lxc.ts +19 -15
- package/src/proxmox/members.test.ts +1 -1
- package/src/proxmox/members.ts +3 -3
- package/src/proxmox/metric-server-otel.ts +2 -2
- package/src/proxmox/network-apply-read.ts +10 -10
- package/src/proxmox/network-apply.ts +10 -10
- package/src/proxmox/node-network-form.ts +7 -7
- package/src/proxmox/node-network.ts +18 -18
- package/src/proxmox/notification-target.ts +3 -2
- package/src/proxmox/pbs-datastore-guard.ts +1 -1
- package/src/proxmox/pbs-datastore.ts +10 -10
- package/src/proxmox/pbs-prune-job.ts +2 -2
- package/src/proxmox/pbs-sync-job.ts +4 -4
- package/src/proxmox/pbs-verify-job.ts +1 -1
- package/src/proxmox/pool.test.ts +2 -2
- package/src/proxmox/pool.ts +4 -4
- package/src/proxmox/provision-baseline.test.ts +87 -0
- package/src/proxmox/provision-baseline.ts +162 -0
- package/src/proxmox/provision-bootstrap.test.ts +132 -0
- package/src/proxmox/provision-bootstrap.ts +135 -0
- package/src/proxmox/provision-cli-fake.ts +217 -0
- package/src/proxmox/provision-declare.test.ts +111 -0
- package/src/proxmox/provision-declare.ts +103 -0
- package/src/proxmox/replication-job.ts +9 -8
- package/src/proxmox/resource.ts +3 -3
- package/src/proxmox/sdn-apply-read.test.ts +16 -16
- package/src/proxmox/sdn-apply-read.ts +7 -7
- package/src/proxmox/sdn-apply.ts +3 -3
- package/src/proxmox/sdn-subnet-form.ts +1 -1
- package/src/proxmox/sdn-subnet.ts +5 -5
- package/src/proxmox/sdn-vnet.ts +5 -6
- package/src/proxmox/storage.ts +4 -3
- package/src/proxmox/user.ts +3 -2
- package/src/proxmox/values.test.ts +2 -2
- package/src/proxmox/values.ts +1 -1
- package/src/proxmox/zfs-pool-write.ts +1 -1
- package/src/proxmox/zfs-pool.ts +6 -6
- package/src/talos/credentials.ts +3 -3
- package/src/talos/values.test.ts +9 -9
- package/src/verify/cli.test.ts +1 -1
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* ⚠️ PRIVILEGES, AND FOR ONCE NOTHING NEEDS WIDENING. MEASURED 2026-09-13 from the cluster's own
|
|
62
62
|
* schema and its live ACL: the item GET checks `["perm","/",["Sys.Audit"]]`, POST/PUT/DELETE
|
|
63
63
|
* check `["perm","/",["Sys.Modify"]]`, `hf-read@pve` holds `PVEAuditor` at `/` with propagate and
|
|
64
|
-
* `hf-provision@pve` holds
|
|
64
|
+
* `hf-provision@pve` holds the provision role (provision-baseline.ts) at `/` with propagate. Both privileges are already
|
|
65
65
|
* held, so `readRole` stays the default `read` lane — unlike storage/sdn-zone/sdn-vnet, whose
|
|
66
66
|
* ITEM reads are gated on an allocate privilege the auditor cannot have.
|
|
67
67
|
* ⚠️ `Sys.Modify` ON `/` IS STILL A BIG HAMMER — it is the same grant `metric-server.ts` warns
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Generated PBS API types — DO NOT EDIT BY HAND.
|
|
3
3
|
*
|
|
4
4
|
* Run: bun codegen/generate.ts
|
|
5
|
-
* Source:
|
|
5
|
+
* Source: the cluster's own `apidoc.js` (pve-docs), extracted outside this repo.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/** GET /config/datastore — `data` payload after client unwrap. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Generated PVE API types — DO NOT EDIT BY HAND.
|
|
3
3
|
*
|
|
4
4
|
* Run: bun codegen/generate.ts
|
|
5
|
-
* Source:
|
|
5
|
+
* Source: the cluster's own `apidoc.js` (pve-docs), extracted outside this repo.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/** GET /cluster/replication — `data` payload after client unwrap. */
|
package/src/proxmox/group.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Proxmox.Group` — a PVE group: a name, a comment, and the set of users PVE hangs off it.
|
|
3
3
|
*
|
|
4
|
-
* ★ DECLARING `hf-mint` IS THE WHOLE POINT OF THIS FAMILY. `
|
|
4
|
+
* ★ DECLARING `hf-mint` IS THE WHOLE POINT OF THIS FAMILY. `mint@pve` holds the `MintTokens` role
|
|
5
5
|
* ON THE PATH `/access/groups/hf-mint` — measured from `GET /access/acl`, 2026-09-13 — which is
|
|
6
6
|
* what confines the OpenBao proxmox engine to minting inside that one group. The group is the
|
|
7
7
|
* fence around the credential every other resource in this package runs on, and until now it was
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* in this file could ever settle.
|
|
18
18
|
*
|
|
19
19
|
* ⛔ THE INDEX AND THE ITEM DISAGREE ABOUT BOTH THE NAME AND THE TYPE, and this file reads only the
|
|
20
|
-
* item. MEASURED against
|
|
20
|
+
* item. MEASURED against node-b, 2026-09-13:
|
|
21
21
|
* GET /access/groups -> [{"groupid":"hf-mint","comment":"…",
|
|
22
22
|
* "users":"hf-provision@pve,hf-read@pve"}, …]
|
|
23
23
|
* GET /access/groups/hf-mint -> {"comment":"…","members":["hf-provision@pve","hf-read@pve"]}
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
*
|
|
28
28
|
* ⛔ AND THE ITEM'S ORDER IS NOT STABLE BETWEEN TWO CONSECUTIVE CALLS. MEASURED, seconds apart, on
|
|
29
29
|
* the same endpoint:
|
|
30
|
-
* ["root@pam","
|
|
31
|
-
* ["
|
|
30
|
+
* ["root@pam","alice@pve","alice@pam","alice@example.com@corp"]
|
|
31
|
+
* ["alice@pam","alice@pve","alice@example.com@corp","root@pam"]
|
|
32
32
|
* `read_group` builds it as `[keys %{ $data->{users} }]` — a bare Perl hash key list, whose order
|
|
33
33
|
* is randomised per process — while the INDEX sorts (`join(',', sort keys …)`). So the array is
|
|
34
34
|
* sorted on the way into `attributes` as well as being kept out of `matches`: unsorted, Alchemy's
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* So `readRole` is left at the default `read` — unlike storage/sdn-zone/sdn-vnet, whose ITEM
|
|
46
46
|
* reads PVE gates on an allocate privilege. See the ⛔ on `readRole` in `resource.ts`.
|
|
47
47
|
* · POST, PUT and DELETE each check `Group.Allocate` on `/access/groups`, and
|
|
48
|
-
* `hf-provision@pve` holds it via `
|
|
48
|
+
* `hf-provision@pve` holds it via the provision role (`PROVISION_PRIVILEGES`).
|
|
49
49
|
*
|
|
50
50
|
* ⛔ `retain` BY DEFAULT, BECAUSE DELETING A GROUP DESTROYS TWO THINGS THIS FILE CANNOT PUT BACK.
|
|
51
51
|
* Measured in `PVE::AccessControl` on the node:
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
* <comment>:`) and DERIVES each user's `groups` map from it at parse time. Deleting the group
|
|
54
54
|
* deletes the list, and this resource has no `members` prop to restore it from.
|
|
55
55
|
* · `delete_group_acl` then walks the whole ACL tree and drops every grant where the group is
|
|
56
|
-
* the SUBJECT. On this cluster that is `
|
|
57
|
-
* `
|
|
56
|
+
* the SUBJECT. On this cluster that is `admins -> Administrator on /` and
|
|
57
|
+
* `automation -> PVEAuditor on /`: whole populations of access, gone in one call, with no
|
|
58
58
|
* confirmation and nothing in the plan to suggest it.
|
|
59
59
|
* `delete` is FULLY IMPLEMENTED — `DELETE /access/groups/{groupid}` exists, unlike the ACL
|
|
60
60
|
* family's — so `.pipe(RemovalPolicy.destroy())` really removes the group. See the ★ in
|
|
@@ -120,7 +120,7 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
|
|
|
120
120
|
* back as absent. Left alone, `matches` would be false on every plan and every deploy would
|
|
121
121
|
* rewrite the same value into the same hole. Normalising BOTH the comparison and the form is how
|
|
122
122
|
* `values.ts` already treats `''` as the absence of a boolean rather than as `false`.
|
|
123
|
-
* ⚠️ MEASURED IN THE SOURCE ON
|
|
123
|
+
* ⚠️ MEASURED IN THE SOURCE ON node-b, NOT ON THE WIRE — writing `'0'` to the cluster to watch it
|
|
124
124
|
* vanish would have been a write, and this agent had read access only.
|
|
125
125
|
* ⚠️ `pool.ts` HAS THE SAME LATENT HOLE — `pool:$pool:$comment:…` is written by that same
|
|
126
126
|
* truthiness test and is not guarded. Not fixed from here; flagged so it is fixed on purpose.
|
|
@@ -160,7 +160,7 @@ const handlers = pveHandlers<GroupProps, GroupAttributes>({
|
|
|
160
160
|
* without one answers `{"members":[…]}` and nothing else. THAT ONE IS READ OFF THE HANDLER'S
|
|
161
161
|
* SOURCE, not off the wire — every group on this cluster happens to carry a comment, so the
|
|
162
162
|
* commentless shape was not there to measure. The empty-MEMBERS shape WAS measured —
|
|
163
|
-
* `
|
|
163
|
+
* `automation` answers `{"comment":"…","members":[]}`. `''` is what an undeclared
|
|
164
164
|
* `comment` prop normalises to, so the two sides meet either way.
|
|
165
165
|
*/
|
|
166
166
|
comment: text(live['comment']),
|
|
@@ -190,7 +190,7 @@ const handlers = pveHandlers<GroupProps, GroupAttributes>({
|
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
192
|
* ⛔ THE EMPTY `list` IS INHERITED FROM `pveHandlers` AND IT MATTERS HERE. `GET /access/groups`
|
|
193
|
-
* answers with every group on the cluster — on this one that is `
|
|
193
|
+
* answers with every group on the cluster — on this one that is `admins`, which grants
|
|
194
194
|
* `Administrator` on `/` to every human who logs in. Handing it to Alchemy would invite adoption,
|
|
195
195
|
* and therefore one day a delete that takes the whole admin group's access with it. Adoption
|
|
196
196
|
* stays an explicit act, here as everywhere else in this package.
|
|
@@ -35,10 +35,11 @@
|
|
|
35
35
|
* (node-affinity and resource-affinity) and that is the next family to add; do not port the old
|
|
36
36
|
* groups endpoint back just because the docs still describe it.
|
|
37
37
|
*
|
|
38
|
-
* ⚠️ RECONCILE NEEDS `Sys.Console` ON `/`, WHICH
|
|
38
|
+
* ⚠️ RECONCILE NEEDS `Sys.Console` ON `/`, WHICH THE PROVISION ROLE LACKED WHEN THIS WAS WRITTEN
|
|
39
|
+
* (`PROVISION_PRIVILEGES` carries it now — ha-rule.ts has the measurement). Read and diff are
|
|
39
40
|
* fine — `GET /cluster/ha/resources/{sid}` checks `Sys.Audit`, which the role already has — while
|
|
40
41
|
* POST, PUT and DELETE all check `Sys.Console` on `/` and will answer "Permission check failed
|
|
41
|
-
* (/, Sys.Console)"
|
|
42
|
+
* (/, Sys.Console)" on a role without it. Widen it knowingly: `Sys.Console` is also what
|
|
42
43
|
* opens a root shell on every node (`/nodes/{node}/vncshell`, `termproxy`), so granting it to the
|
|
43
44
|
* provision credential buys HA membership at the price of node console access. A separate role
|
|
44
45
|
* for HA writes is the narrower answer if that trade is not wanted.
|
|
@@ -16,21 +16,21 @@ import { csv } from './values.ts';
|
|
|
16
16
|
* A `<node>[:<pri>]` list, spelled the way PVE gives it back.
|
|
17
17
|
*
|
|
18
18
|
* ⛔ THIS IS NOT `csv`, AND THE DIFFERENCE IS A FOREVER-DIFF. MEASURED by reading
|
|
19
|
-
* `/usr/share/perl5/PVE/HA/Rules/NodeAffinity.pm` on
|
|
19
|
+
* `/usr/share/perl5/PVE/HA/Rules/NodeAffinity.pm` on node-b (2026-09-13): `decode_plugin_value`
|
|
20
20
|
* turns the list into a HASH keyed by node name, and `encode_plugin_value` rebuilds it with
|
|
21
21
|
* `for my $node (sort keys %$value)` — sorted by NODE NAME, never by the token — emitting the
|
|
22
22
|
* bare name whenever the priority is falsy, because `PVE::HA::Tools::parse_node_priority` reads
|
|
23
|
-
* a missing priority as 0. So a declared `
|
|
23
|
+
* a missing priority as 0. So a declared `node-c:1,node-b:0` is handed back as `node-b,node-c:1`: re-ordered AND
|
|
24
24
|
* re-spelled. Compare the raw strings and every plan reports an update, forever.
|
|
25
25
|
*
|
|
26
26
|
* ⚠️ SORTED BY NAME, NOT BY TOKEN, AND THE TWO GENUINELY DIVERGE. `csv` sorts whole tokens. Node
|
|
27
27
|
* names may contain `-` — `parse_node_priority`'s own regex is
|
|
28
|
-
* `[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?` — and `-` (0x2D) sorts before `:` (0x3A), so `
|
|
29
|
-
* and `
|
|
28
|
+
* `[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?` — and `-` (0x2D) sorts before `:` (0x3A), so `node-b-a`
|
|
29
|
+
* and `node-b:5` come out in one order by token and the other by name. PVE sorts by the name, so
|
|
30
30
|
* this does too.
|
|
31
31
|
*
|
|
32
32
|
* ⚠️ LAST ENTRY WINS FOR A REPEATED NODE, because PVE's decode is a hash assignment and a hash
|
|
33
|
-
* keeps one value per key. A declaration saying `
|
|
33
|
+
* keeps one value per key. A declaration saying `node-b:1,node-b:5` is stored as `node-b:5`; matching that
|
|
34
34
|
* is what a Map gives for free.
|
|
35
35
|
*/
|
|
36
36
|
export const nodeList = (value: readonly string[] | string | undefined) => {
|
package/src/proxmox/ha-rule.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Proxmox.HaRule` — where the CRM may place a guest, and which guests must not share a node.
|
|
3
3
|
*
|
|
4
|
-
* ★ THIS IS THE PVE 9 SUCCESSOR TO HA GROUPS, AND THE GROUPS ENDPOINT IS GONE. MEASURED on
|
|
4
|
+
* ★ THIS IS THE PVE 9 SUCCESSOR TO HA GROUPS, AND THE GROUPS ENDPOINT IS GONE. MEASURED on node-b
|
|
5
5
|
* (pve-manager/9.2.11, 2026-09-13): `pvesh get /cluster/ha/groups` answers `cannot index groups:
|
|
6
6
|
* ha groups have been migrated to rules`, and `/etc/pve/ha/` holds `rules.cfg` with no
|
|
7
7
|
* `groups.cfg` beside it. The published schema still documents groups and still offers a `group`
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
* ⚠️ BOTH LANES ALREADY HAVE THE PRIVILEGES, WHICH IS NEW. MEASURED 2026-09-13:
|
|
49
49
|
* GET (collection and item) checks `Sys.Audit` on `/` and the built-in `PVEAuditor` holds it, so
|
|
50
50
|
* `readRole` stays the default `read` lease — unlike storage.ts and the SDN families. POST, PUT
|
|
51
|
-
* and DELETE all check `Sys.Console` on `/`, and
|
|
52
|
-
*
|
|
51
|
+
* and DELETE all check `Sys.Console` on `/`, and the provision role now returns `Sys.Console` among
|
|
52
|
+
* its 27 privileges (`PROVISION_PRIVILEGES`, provision-baseline.ts). The warning in ha-resource.ts's header — that
|
|
53
53
|
* `Sys.Console` is missing — is stale; the role was widened, and the price it named still stands:
|
|
54
54
|
* `Sys.Console` is also what opens a root shell on every node.
|
|
55
55
|
*
|
|
@@ -40,6 +40,13 @@ test('control-plane Resource constructors are on the public barrel', () => {
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test('the provisioning baseline is on the barrel: the list, the declaration, the bootstrap', async () => {
|
|
44
|
+
const barrel = await import('./index.ts');
|
|
45
|
+
expect(barrel.PROVISION_PRIVILEGES).toHaveLength(27);
|
|
46
|
+
expect(typeof barrel.declareProvisionBaseline).toBe('function');
|
|
47
|
+
expect(barrel.provisionBootstrap()).toStartWith('#!/bin/sh\n');
|
|
48
|
+
});
|
|
49
|
+
|
|
43
50
|
test('QEMU and NIC apply stay Provider-only', () => {
|
|
44
51
|
expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
|
|
45
52
|
expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
|
package/src/proxmox/index.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* The provider-only version diffed four fields and would have planned a create over any read
|
|
13
13
|
* failure. The Resource now adopts from the live config, refuses every change PVE cannot make
|
|
14
14
|
* in place, and retains on destroy (lxc.ts).
|
|
15
|
+
* ★ THE PROVISIONING BASELINE JOINED 2026-09-21: one privilege list (`PROVISION_PRIVILEGES`), the
|
|
16
|
+
* helper that declares it (`declareProvisionBaseline`) and the pure generator of the one-time
|
|
17
|
+
* root commands that bootstrap it (`provisionBootstrap`) — docs/provision-baseline.md.
|
|
15
18
|
* ★ Anything else unlisted is still reachable by path if you genuinely need
|
|
16
19
|
* it — that is a deliberate, visible act rather than an accident of barrelling.
|
|
17
20
|
*/
|
|
@@ -40,6 +43,17 @@ export { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
|
|
|
40
43
|
export { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
|
|
41
44
|
export { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
|
|
42
45
|
export { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
|
|
46
|
+
export {
|
|
47
|
+
PROVISION_DEFAULTS,
|
|
48
|
+
PROVISION_PRIVILEGES,
|
|
49
|
+
type ProvisionBaseline,
|
|
50
|
+
type ProvisionLane,
|
|
51
|
+
type ProvisionNames,
|
|
52
|
+
type ResolvedProvisionNames,
|
|
53
|
+
provisionBaseline,
|
|
54
|
+
} from './provision-baseline.ts';
|
|
55
|
+
export { provisionBootstrap } from './provision-bootstrap.ts';
|
|
56
|
+
export { type DeclareProvisionOptions, declareProvisionBaseline } from './provision-declare.ts';
|
|
43
57
|
export { ProxmoxVmProvider } from './qemu.ts';
|
|
44
58
|
export { ProxmoxReplicationJobProvider } from './replication-job.ts';
|
|
45
59
|
export { ProxmoxRole, ProxmoxRoleProvider } from './role.ts';
|
|
@@ -17,13 +17,13 @@ import * as Exit from 'effect/Exit';
|
|
|
17
17
|
import type { PveCredential } from './credentials.ts';
|
|
18
18
|
import { type LeaseKey, makeLeases, timeToLive } from './lease-cache.ts';
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
mount: 'proxmox-
|
|
20
|
+
const C1: LeaseKey = {
|
|
21
|
+
mount: 'proxmox-c1',
|
|
22
22
|
role: 'read',
|
|
23
23
|
scheme: 'pve',
|
|
24
24
|
};
|
|
25
|
-
/** ⚠️ Same role as
|
|
26
|
-
const
|
|
25
|
+
/** ⚠️ Same role as C1, DIFFERENT mount — the collision the key has to survive. */
|
|
26
|
+
const C2: LeaseKey = { ...C1, mount: 'proxmox-c2' };
|
|
27
27
|
|
|
28
28
|
const cred = (tokenId: string, leaseSeconds: number): PveCredential => ({
|
|
29
29
|
leaseSeconds,
|
|
@@ -85,10 +85,10 @@ describe('lease cache under concurrency', () => {
|
|
|
85
85
|
await Effect.runPromise(
|
|
86
86
|
Effect.gen(function* () {
|
|
87
87
|
const leases = yield* makeLeases(mint.mintFor);
|
|
88
|
-
const exits = yield* concurrently(20, Cache.get(leases,
|
|
88
|
+
const exits = yield* concurrently(20, Cache.get(leases, C1));
|
|
89
89
|
assert.equal(exits.filter(Exit.isSuccess).length, 20);
|
|
90
90
|
// An equal but separately built key is the same entry.
|
|
91
|
-
yield* Cache.get(leases, { ...
|
|
91
|
+
yield* Cache.get(leases, { ...C1 });
|
|
92
92
|
}),
|
|
93
93
|
);
|
|
94
94
|
assert.equal(mint.calls(), 1);
|
|
@@ -102,9 +102,9 @@ describe('lease cache under concurrency', () => {
|
|
|
102
102
|
await Effect.runPromise(
|
|
103
103
|
Effect.gen(function* () {
|
|
104
104
|
const leases = yield* makeLeases(mint.mintFor);
|
|
105
|
-
const first = yield* concurrently(20, Cache.get(leases,
|
|
105
|
+
const first = yield* concurrently(20, Cache.get(leases, C1));
|
|
106
106
|
assert.equal(first.filter(Exit.isFailure).length, 20, 'the waiters share the failure');
|
|
107
|
-
const second = yield* concurrently(20, Cache.get(leases,
|
|
107
|
+
const second = yield* concurrently(20, Cache.get(leases, C1));
|
|
108
108
|
assert.equal(second.filter(Exit.isSuccess).length, 20);
|
|
109
109
|
}),
|
|
110
110
|
);
|
|
@@ -116,8 +116,8 @@ describe('lease cache under concurrency', () => {
|
|
|
116
116
|
await Effect.runPromise(
|
|
117
117
|
Effect.gen(function* () {
|
|
118
118
|
const leases = yield* makeLeases(mint.mintFor);
|
|
119
|
-
yield* concurrently(20, Cache.get(leases,
|
|
120
|
-
yield* Cache.get(leases,
|
|
119
|
+
yield* concurrently(20, Cache.get(leases, C1));
|
|
120
|
+
yield* Cache.get(leases, C1);
|
|
121
121
|
}),
|
|
122
122
|
);
|
|
123
123
|
assert.equal(mint.calls(), 2);
|
|
@@ -125,25 +125,25 @@ describe('lease cache under concurrency', () => {
|
|
|
125
125
|
|
|
126
126
|
it('does not serve one cluster the credential of another with the same mount and role', async () => {
|
|
127
127
|
const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
|
|
128
|
-
const [
|
|
128
|
+
const [c1, c2] = await Effect.runPromise(
|
|
129
129
|
Effect.gen(function* () {
|
|
130
130
|
const leases = yield* makeLeases(mint.mintFor);
|
|
131
|
-
return [yield* Cache.get(leases,
|
|
131
|
+
return [yield* Cache.get(leases, C1), yield* Cache.get(leases, C2)] as const;
|
|
132
132
|
}),
|
|
133
133
|
);
|
|
134
|
-
assert.notEqual(
|
|
134
|
+
assert.notEqual(c1.tokenId, c2.tokenId);
|
|
135
135
|
assert.equal(mint.calls(), 2);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
it('shares one lease across two members of the same cluster mount', async () => {
|
|
139
139
|
const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
|
|
140
|
-
const
|
|
141
|
-
const
|
|
140
|
+
const NODE_B: LeaseKey = { mount: 'proxmox-c1', role: 'read', scheme: 'pve' };
|
|
141
|
+
const NODE_C: LeaseKey = { mount: 'proxmox-c1', role: 'read', scheme: 'pve' };
|
|
142
142
|
await Effect.runPromise(
|
|
143
143
|
Effect.gen(function* () {
|
|
144
144
|
const leases = yield* makeLeases(mint.mintFor);
|
|
145
|
-
yield* Cache.get(leases,
|
|
146
|
-
yield* Cache.get(leases,
|
|
145
|
+
yield* Cache.get(leases, NODE_B);
|
|
146
|
+
yield* Cache.get(leases, NODE_C);
|
|
147
147
|
}),
|
|
148
148
|
);
|
|
149
149
|
assert.equal(mint.calls(), 1);
|
|
@@ -151,11 +151,11 @@ describe('lease cache under concurrency', () => {
|
|
|
151
151
|
|
|
152
152
|
it('keeps read and provision apart', async () => {
|
|
153
153
|
const mint = countingMint((call) => cred(`hf@pve!${String(call)}`, 3600));
|
|
154
|
-
const PROVISION: LeaseKey = { ...
|
|
154
|
+
const PROVISION: LeaseKey = { ...C1, role: 'provision' };
|
|
155
155
|
const [read, provision] = await Effect.runPromise(
|
|
156
156
|
Effect.gen(function* () {
|
|
157
157
|
const leases = yield* makeLeases(mint.mintFor);
|
|
158
|
-
return [yield* Cache.get(leases,
|
|
158
|
+
return [yield* Cache.get(leases, C1), yield* Cache.get(leases, PROVISION)] as const;
|
|
159
159
|
}),
|
|
160
160
|
);
|
|
161
161
|
assert.notEqual(read.tokenId, provision.tokenId);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One credential per (target, role) for the life of a run, instead of one per API call.
|
|
3
3
|
*
|
|
4
|
-
* 🔴 THE MEASUREMENT THAT MADE THIS NECESSARY. A single `alchemy plan` plus `deploy` of the
|
|
5
|
-
* stack — 98 resources — left **760 API token entries in `/etc/pve/user.cfg`**, counted on
|
|
4
|
+
* 🔴 THE MEASUREMENT THAT MADE THIS NECESSARY. A single `alchemy plan` plus `deploy` of the C1
|
|
5
|
+
* stack — 98 resources — left **760 API token entries in `/etc/pve/user.cfg`**, counted on node-b
|
|
6
6
|
* immediately afterwards. `mint()` asked OpenBao for a new credential on EVERY call, and each
|
|
7
7
|
* mint creates a real Proxmox token: read, diff, reconcile and read-back each got their own. They
|
|
8
8
|
* drain as the leases expire (760 down to 712 over five minutes), so nothing was broken — but
|
|
@@ -66,9 +66,9 @@ const REMINT_MARGIN_SECONDS = 60;
|
|
|
66
66
|
* What a credential is cached under.
|
|
67
67
|
*
|
|
68
68
|
* ⛔ KEYED ON THE OPENBAO MOUNT AND ROLE, NOT ON A MEMBER HOSTNAME. A PVE API token is
|
|
69
|
-
* cluster-wide — `user.cfg` is replicated — so
|
|
69
|
+
* cluster-wide — `user.cfg` is replicated — so node-b, node-c and node-d share one credential. Keying on
|
|
70
70
|
* `api` would mint three identical tokens when failover rotates members.
|
|
71
|
-
* ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-
|
|
71
|
+
* ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-c1`, `proxmox-c2` and `pbs-c1` are
|
|
72
72
|
* distinct OpenBao mounts with distinct policies; two estates never share a mount name.
|
|
73
73
|
* ★ A PLAIN OBJECT IS A SAFE KEY BECAUSE EFFECT COMPARES KEYS STRUCTURALLY — two separately built
|
|
74
74
|
* literals with the same fields are one entry, which the probe above measured.
|
|
@@ -4,28 +4,20 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
|
|
6
6
|
* its deploy writes NOTHING, and the next plan is `noop`; the create spellings (`storage:size`,
|
|
7
|
-
* a NIC without its MAC) adopt as cleanly; drift is named at plan and
|
|
8
|
-
* keeps the live MAC and carries the digest; and every change PVE cannot
|
|
9
|
-
* plan with nothing written. Deleting both empty-change guards
|
|
10
|
-
* `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
|
|
11
|
-
* each MEASURED by making that edit and running this file,
|
|
7
|
+
* a NIC without its MAC) adopt as cleanly; after that adoption, drift is named at plan and
|
|
8
|
+
* written as one PUT that keeps the live MAC and carries the digest; and every change PVE cannot
|
|
9
|
+
* make in place fails the plan with nothing written. Deleting both empty-change guards
|
|
10
|
+
* (reconcile's in lxc.ts and `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
|
|
11
|
+
* lxc-wire.ts, fails a test here — each MEASURED by making that edit and running this file,
|
|
12
|
+
* 2026-09-21. An adoption that WOULD write fails instead: lxc-strict-adopt.test.ts.
|
|
12
13
|
*/
|
|
13
14
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
14
15
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
15
16
|
import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
|
|
16
|
-
import {
|
|
17
|
+
import { NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
|
|
17
18
|
import { ProxmoxLxc } from './lxc.ts';
|
|
18
19
|
import type { LxcProps } from './lxc-props.ts';
|
|
19
20
|
|
|
20
|
-
const NODE = 'pve1';
|
|
21
|
-
const VMID = 100;
|
|
22
|
-
|
|
23
|
-
/** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
|
|
24
|
-
const pasted = (): LxcProps => {
|
|
25
|
-
const { lxc: _raw, ...config } = LIVE;
|
|
26
|
-
return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID };
|
|
27
|
-
};
|
|
28
|
-
|
|
29
21
|
const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
|
|
30
22
|
|
|
31
23
|
let fake: FakePve | undefined;
|
|
@@ -84,17 +76,17 @@ describe('adopting a live container', () => {
|
|
|
84
76
|
});
|
|
85
77
|
});
|
|
86
78
|
|
|
87
|
-
describe('drift
|
|
79
|
+
describe('drift after the adoption is named at plan and written once', () => {
|
|
88
80
|
test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
|
|
89
81
|
const pve = live();
|
|
90
|
-
const stack =
|
|
82
|
+
const stack = await adoptedAsIs(pve);
|
|
91
83
|
const props: LxcProps = {
|
|
92
84
|
...pasted(),
|
|
93
85
|
memory: 4096,
|
|
94
86
|
net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
|
|
95
87
|
};
|
|
96
88
|
const planned = await stack.plan(ct(props));
|
|
97
|
-
expect(planned.actions).toEqual({ ct: '
|
|
89
|
+
expect(planned.actions).toEqual({ ct: 'update' });
|
|
98
90
|
expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
|
|
99
91
|
expect((await stack.deploy(ct(props))).failure).toBe('');
|
|
100
92
|
expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
|
|
@@ -107,8 +99,8 @@ describe('drift at adoption is named at plan and written once', () => {
|
|
|
107
99
|
|
|
108
100
|
test('a larger rootfs is a resize after the config, never a new volume', async () => {
|
|
109
101
|
const pve = live();
|
|
110
|
-
const stack =
|
|
111
|
-
const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-
|
|
102
|
+
const stack = await adoptedAsIs(pve);
|
|
103
|
+
const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-900-disk-0,size=64G' };
|
|
112
104
|
expect((await stack.deploy(ct(props))).failure).toBe('');
|
|
113
105
|
expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
|
|
114
106
|
expect(pve.guests.get(`${NODE}/${String(VMID)}`)?.['rootfs']).toContain('size=64G');
|
|
@@ -117,15 +109,16 @@ describe('drift at adoption is named at plan and written once', () => {
|
|
|
117
109
|
|
|
118
110
|
describe('changes PVE cannot make in place fail the plan and write nothing', () => {
|
|
119
111
|
test.each([
|
|
120
|
-
['a smaller mount point', { mp0: 'tank:subvol-
|
|
112
|
+
['a smaller mount point', { mp0: 'tank:subvol-900-disk-0,mp=/data,size=100G' }, /shrink/],
|
|
121
113
|
['a storage move', { rootfs: 'other:40' }, /pct move-volume/],
|
|
122
114
|
['an unprivileged flip', { unprivileged: 0 as const }, /read-only option/],
|
|
123
|
-
['a device passthrough change', { dev1: '/dev/fuse' }, /pct set
|
|
115
|
+
['a device passthrough change', { dev1: '/dev/fuse' }, /pct set 900 --dev1/],
|
|
124
116
|
['a feature beyond nesting', { features: 'nesting=1,keyctl=1' }, /root@pam/],
|
|
125
117
|
['detaching a mount point', { mp0: '' }, /unusedN/],
|
|
126
118
|
])('%s', async (_, change, reason) => {
|
|
127
119
|
const pve = live();
|
|
128
|
-
const
|
|
120
|
+
const stack = await adoptedAsIs(pve);
|
|
121
|
+
const run = await stack.deploy(ct({ ...pasted(), ...change } as LxcProps));
|
|
129
122
|
expect(run.failure).toMatch(reason);
|
|
130
123
|
expect(writesOf(pve)).toEqual([]);
|
|
131
124
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strict adoption gate for `Proxmox.Lxc`: AN ADOPTION NEVER CHANGES A GUEST (decided
|
|
3
|
+
* 2026-09-21). A guest this stack adopts — no recorded instance, or one the plan only kept because
|
|
4
|
+
* `--adopt` said so (ownership/adopting.ts) — is taken over exactly as it runs, or not at all.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ ANY DIFFERENCE FAILS, AT PLAN AND AGAIN AT APPLY. Before this, a cold adoption planned `adopted`
|
|
7
|
+
* whatever it would write (alchemy beta.79 prints no property diff for it) and only LOGGED the
|
|
8
|
+
* keys; `deploy --adopt --yes` then wrote them. A `net0` pasted without the live `tag=` moved the
|
|
9
|
+
* guest off its VLAN on the deploy that was meant to change nothing. Now the plan fails naming
|
|
10
|
+
* the keys, and reconcile re-asks against a fresh read, so a hand edit between plan and deploy
|
|
11
|
+
* is refused too rather than written back.
|
|
12
|
+
* ⛔ KEYS ONLY, NEVER VALUES. The refusal names what differs and nothing it differs by: a live
|
|
13
|
+
* config can carry anything (`description` is where people paste credentials), and plan output
|
|
14
|
+
* lands in CI logs.
|
|
15
|
+
* ★ ASKED BEFORE `judge`'s OWN REFUSALS. Those name a declared value (the `pct set` to run), and
|
|
16
|
+
* for an adoption the fix is the other direction: declare what is live.
|
|
17
|
+
* ★ THE STORE IS READ ONLY WHEN SOMETHING DIFFERS. A clean adoption — the only kind that proceeds
|
|
18
|
+
* — costs nothing here.
|
|
19
|
+
*/
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import type { Owner } from '../ownership/adopt.ts';
|
|
22
|
+
import { adopting } from '../ownership/adopting.ts';
|
|
23
|
+
import { type LxcChange, judge } from './lxc-judge.ts';
|
|
24
|
+
import { LxcRefusedError } from './lxc-lifecycle.ts';
|
|
25
|
+
import type { LxcProps } from './lxc-props.ts';
|
|
26
|
+
|
|
27
|
+
/** The refusal: keys, where, and what to do — never a value. */
|
|
28
|
+
export const adoptionRefusal = (props: LxcProps, drift: readonly string[]): string =>
|
|
29
|
+
`CT ${String(props.vmid)} on ${props.node}: adopting it would change ${drift.join(', ')}. An ` +
|
|
30
|
+
'adoption never changes a guest, so nothing is written. Declare what is live for these keys ' +
|
|
31
|
+
'(the plan names keys, never values) and plan again; change them after the adoption, as an ' +
|
|
32
|
+
'update.';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fails with `adoptionRefusal` when `change` would write to a guest `owner`'s call adopts.
|
|
36
|
+
* `settled` for a recovered create is the same judge, of the recording row's props against `live`.
|
|
37
|
+
*/
|
|
38
|
+
export const refuseAdoptedDrift = (
|
|
39
|
+
owner: Owner,
|
|
40
|
+
props: LxcProps,
|
|
41
|
+
live: Record<string, unknown>,
|
|
42
|
+
change: LxcChange,
|
|
43
|
+
): Effect.Effect<void, LxcRefusedError> =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
if (change.drift.length === 0) return;
|
|
46
|
+
const settled = (recorded: unknown) =>
|
|
47
|
+
Effect.sync(() => judge(recorded as LxcProps, live).drift.length === 0);
|
|
48
|
+
if (!(yield* adopting(owner, settled))) return;
|
|
49
|
+
return yield* Effect.fail(new LxcRefusedError(adoptionRefusal(props, change.drift)));
|
|
50
|
+
});
|
|
@@ -23,7 +23,7 @@ import { bool } from './values.ts';
|
|
|
23
23
|
/**
|
|
24
24
|
* ⛔ A CREATE ALLOCATES NEW VOLUMES ONLY. pve-container's `create_disks` allocates `storage:GiB`
|
|
25
25
|
* and takes anything else as "use specified/existing volid", then `restore_archive` unpacks the
|
|
26
|
-
* template into the mounted rootfs. So a create naming `local-zfs:subvol-
|
|
26
|
+
* template into the mounted rootfs. So a create naming `local-zfs:subvol-900-disk-0` — a pasted
|
|
27
27
|
* adoption, re-created after its config vanished while the volume survived (a `pct destroy`
|
|
28
28
|
* only WARNS when a volume will not delete) — unpacks a template over the data on it.
|
|
29
29
|
*/
|
|
@@ -11,19 +11,12 @@
|
|
|
11
11
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
12
12
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
13
13
|
import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
|
|
14
|
-
import {
|
|
14
|
+
import { KEY, NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
|
|
15
15
|
import { ProxmoxLxc } from './lxc.ts';
|
|
16
16
|
import type { LxcProps } from './lxc-props.ts';
|
|
17
17
|
|
|
18
|
-
const NODE = 'pve1';
|
|
19
|
-
const VMID = 100;
|
|
20
|
-
const KEY = `${NODE}/${String(VMID)}`;
|
|
21
18
|
const TEMPLATE = 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst';
|
|
22
|
-
|
|
23
|
-
const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
|
|
24
|
-
const { lxc: _raw, ...config } = LIVE;
|
|
25
|
-
return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
|
|
26
|
-
};
|
|
19
|
+
const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
|
|
27
20
|
|
|
28
21
|
let fake: FakePve | undefined;
|
|
29
22
|
afterEach(() => {
|
|
@@ -70,8 +63,8 @@ describe('a guest the cluster no longer lists, with state still held', () => {
|
|
|
70
63
|
expect((await stack.deploy(declared())).failure).toBe('');
|
|
71
64
|
pve.guests.delete(KEY);
|
|
72
65
|
const run = await stack.deploy(declared());
|
|
73
|
-
expect(run.failure).toContain('rootfs: local-zfs:subvol-
|
|
74
|
-
expect(run.failure).toContain('mp0: tank:subvol-
|
|
66
|
+
expect(run.failure).toContain('rootfs: local-zfs:subvol-900-disk-0,size=40G names an existing');
|
|
67
|
+
expect(run.failure).toContain('mp0: tank:subvol-900-disk-0');
|
|
75
68
|
expect(writesOf(pve)).toEqual([]);
|
|
76
69
|
});
|
|
77
70
|
|
|
@@ -113,10 +106,9 @@ describe('a write is claimed only when PVE says it finished', () => {
|
|
|
113
106
|
test('a config write PVE accepted but did not store fails the read-back', async () => {
|
|
114
107
|
const pve = cluster();
|
|
115
108
|
seed(pve, NODE, VMID);
|
|
109
|
+
const stack = await adoptedAsIs(pve);
|
|
116
110
|
pve.ignore.add('memory');
|
|
117
|
-
const run = await
|
|
118
|
-
ProxmoxLxc('ct', pasted({ memory: 4096 })).pipe(adopt(true)),
|
|
119
|
-
);
|
|
111
|
+
const run = await stack.deploy(ct(pasted({ memory: 4096 })));
|
|
120
112
|
expect(run.failure).toContain('memory still differ');
|
|
121
113
|
});
|
|
122
114
|
});
|
|
@@ -137,13 +129,14 @@ describe('identity and volumes', () => {
|
|
|
137
129
|
test('options and growth on one volume: the PUT keeps the live size, the resize follows', async () => {
|
|
138
130
|
const pve = cluster();
|
|
139
131
|
seed(pve, NODE, VMID);
|
|
140
|
-
const
|
|
141
|
-
const
|
|
132
|
+
const stack = await adoptedAsIs(pve);
|
|
133
|
+
const mp0 = 'tank:subvol-900-disk-0,mp=/data,backup=1,size=300G';
|
|
134
|
+
const run = await stack.deploy(ct(pasted({ mp0 })));
|
|
142
135
|
expect(run.failure).toBe('');
|
|
143
136
|
const path = `nodes/${NODE}/lxc/${String(VMID)}`;
|
|
144
137
|
expect(writesOf(pve)).toEqual([`PUT ${path}/config`, `PUT ${path}/resize`]);
|
|
145
138
|
const put = pve.seen.find((call) => call.path.endsWith('/config') && call.method === 'PUT');
|
|
146
|
-
expect(put?.form.get('mp0')).toBe('tank:subvol-
|
|
139
|
+
expect(put?.form.get('mp0')).toBe('tank:subvol-900-disk-0,mp=/data,backup=1,size=200G');
|
|
147
140
|
expect(pve.guests.get(KEY)?.['mp0']).toContain('size=300G');
|
|
148
141
|
});
|
|
149
142
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* ★ ONE ARTIFACT STORE ACROSS A PLAN AND ITS APPLY, and `--adopt` as the AdoptPolicy service, as
|
|
14
14
|
* openbao/fake-stack.ts provides them: the ownership rule reads both (src/ownership/).
|
|
15
15
|
*/
|
|
16
|
-
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
16
|
+
import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
|
|
17
17
|
import { apply } from 'alchemy/Apply';
|
|
18
18
|
import { provideFreshArtifactStore } from 'alchemy/Artifacts';
|
|
19
19
|
import * as Plan from 'alchemy/Plan';
|
|
@@ -29,7 +29,8 @@ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
|
29
29
|
import type { PveTarget } from './credentials.ts';
|
|
30
30
|
import { withFakeBao } from './fake-bao-env.ts';
|
|
31
31
|
import { FAKE_BAO, FAKE_MEMBER, type FakePve } from './fake-pve-lxc.ts';
|
|
32
|
-
import {
|
|
32
|
+
import type { LxcProps } from './lxc-props.ts';
|
|
33
|
+
import { ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
|
|
33
34
|
|
|
34
35
|
export const TARGET: PveTarget = {
|
|
35
36
|
members: [FAKE_MEMBER],
|
|
@@ -53,7 +54,7 @@ export const LIVE: Readonly<Record<string, unknown>> = {
|
|
|
53
54
|
hostname: 'ct-example',
|
|
54
55
|
lxc: [['lxc.prlimit.memlock', 'unlimited']],
|
|
55
56
|
memory: 8192,
|
|
56
|
-
mp0: 'tank:subvol-
|
|
57
|
+
mp0: 'tank:subvol-900-disk-0,mp=/data,backup=0,size=200G',
|
|
57
58
|
nameserver: '192.0.2.254',
|
|
58
59
|
net0:
|
|
59
60
|
'name=eth0,bridge=vmbr0,firewall=0,gw=192.0.2.1,hwaddr=00:00:5E:00:53:01,' +
|
|
@@ -61,13 +62,24 @@ export const LIVE: Readonly<Record<string, unknown>> = {
|
|
|
61
62
|
net1: 'name=eth1,bridge=vmbr1,hwaddr=00:00:5E:00:53:02,ip=198.51.100.4/24,mtu=1500,type=veth',
|
|
62
63
|
onboot: 1,
|
|
63
64
|
ostype: 'debian',
|
|
64
|
-
rootfs: 'local-zfs:subvol-
|
|
65
|
+
rootfs: 'local-zfs:subvol-900-disk-0,size=40G',
|
|
65
66
|
searchdomain: 'example.com',
|
|
66
67
|
swap: 0,
|
|
67
68
|
tags: 'web',
|
|
68
69
|
unprivileged: 1,
|
|
69
70
|
};
|
|
70
71
|
|
|
72
|
+
/** Where `seed` puts the production-shaped guest by default, and its `node/vmid` key. */
|
|
73
|
+
export const NODE = 'pve1';
|
|
74
|
+
export const VMID = 900;
|
|
75
|
+
export const KEY = `${NODE}/${String(VMID)}`;
|
|
76
|
+
|
|
77
|
+
/** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
|
|
78
|
+
export const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
|
|
79
|
+
const { lxc: _raw, ...config } = LIVE;
|
|
80
|
+
return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
|
|
81
|
+
};
|
|
82
|
+
|
|
71
83
|
/** Put `config` live on the fake as `node/vmid`, with a digest as PVE would add. */
|
|
72
84
|
export const seed = (fake: FakePve, node: string, vmid: number, config = LIVE) => {
|
|
73
85
|
fake.guests.set(`${node}/${String(vmid)}`, { ...config, digest: 'digest-live' });
|
|
@@ -148,3 +160,14 @@ export const lxcEngine = (fake: FakePve) => {
|
|
|
148
160
|
status: (fqn: string) => rows['lxc']?.['test']?.[fqn]?.status,
|
|
149
161
|
};
|
|
150
162
|
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The engine, with the guest `seed` put at NODE/VMID adopted exactly as it runs — the only adoption
|
|
166
|
+
* that proceeds (lxc-adoption.ts) — so a test can go on to change it, as an ordinary update.
|
|
167
|
+
*/
|
|
168
|
+
export const adoptedAsIs = async (pve: FakePve): Promise<ReturnType<typeof lxcEngine>> => {
|
|
169
|
+
const stack = lxcEngine(pve);
|
|
170
|
+
const run = await stack.deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)));
|
|
171
|
+
if (run.failure !== '') throw new Error(`the clean adoption failed: ${run.failure}`);
|
|
172
|
+
return stack;
|
|
173
|
+
};
|