@homeflare/alchemy 0.11.0 → 0.13.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 +2 -2
- 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/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.map +14 -14
- 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/proxmox/acl.d.ts +2 -2
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- 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/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +8 -8
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.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 +279 -1
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +58 -1
- package/dist/proxmox/generated/pve.d.ts.map +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 +14 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +935 -223
- package/dist/proxmox/index.js.map +69 -56
- package/dist/proxmox/lease-cache.d.ts +4 -4
- package/dist/proxmox/lxc-harness.d.ts +1 -1
- package/dist/proxmox/lxc-props.d.ts +2 -2
- package/dist/proxmox/lxc.d.ts +3 -3
- 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-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- 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-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- 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 +6 -5
- 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/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- 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/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopt-verify.md +21 -5
- package/docs/adopted-deploys.md +9 -1
- package/docs/ownership.md +26 -1
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/provision-baseline.md +106 -0
- package/docs/proxmox-lxc-adopt.md +2 -2
- package/docs/proxmox-lxc.md +5 -4
- package/docs/proxmox.md +9 -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/proxmox/acl.ts +4 -4
- package/src/proxmox/adopt-noop.test.ts +6 -6
- package/src/proxmox/alertmanager-body.ts +125 -0
- 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/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/credentials.test.ts +6 -8
- package/src/proxmox/credentials.ts +8 -8
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias.ts +1 -1
- package/src/proxmox/generated/pbs.ts +330 -1
- package/src/proxmox/generated/pve.ts +63 -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 +10 -0
- package/src/proxmox/index.ts +32 -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 +3 -3
- package/src/proxmox/lxc-create-form.ts +1 -1
- package/src/proxmox/lxc-guards.test.ts +4 -4
- package/src/proxmox/lxc-harness.ts +3 -3
- package/src/proxmox/lxc-judge.test.ts +5 -5
- package/src/proxmox/lxc-lifecycle.ts +1 -1
- 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 +1 -1
- 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 +3 -3
- 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-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- 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-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- 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 +9 -9
- 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/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- 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
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `provisionBootstrap` — the provisioning baseline (provision-baseline.ts) as the one-time root
|
|
3
|
+
* commands for a NEW cluster or node: a POSIX `sh` script, printed, never run from here.
|
|
4
|
+
*
|
|
5
|
+
* bun -e "import { provisionBootstrap } from '@homeflare/alchemy/proxmox';
|
|
6
|
+
* process.stdout.write(provisionBootstrap())" | ssh root@<node> sh
|
|
7
|
+
*
|
|
8
|
+
* ⛔ WHY IT EXISTS: THE PROVISION LANE CANNOT CREATE ITSELF. Every resource in this package runs as a
|
|
9
|
+
* token OpenBao mints for the provision user, and a cluster without that user, its group and its
|
|
10
|
+
* grant has nothing to mint for — the plan that would create them cannot authenticate. So root
|
|
11
|
+
* makes them once, and the stack adopts and keeps them (provision-declare.ts). This is the only
|
|
12
|
+
* hand step, and it is generated from the same baseline the stack declares.
|
|
13
|
+
* ⛔ NEVER A TOKEN, NEVER A PASSWORD. The users are created with neither: OpenBao mints short-lived
|
|
14
|
+
* tokens for them later, and an `@pve` user with no password cannot log in to the UI. Nothing
|
|
15
|
+
* secret is in the script, so it can be read, reviewed and pasted.
|
|
16
|
+
* ★ CHECK, THEN CHANGE — IDEMPOTENT BY CONSTRUCTION. Each object is read first (`pvesh get … json`,
|
|
17
|
+
* parsed with Perl's core JSON::PP, which every PVE node has), compared with the baseline, and
|
|
18
|
+
* written only when it differs, naming what it does. A second run prints `ok` for everything and
|
|
19
|
+
* writes nothing. It also CONVERGES: a role with other privileges is set to exactly the baseline,
|
|
20
|
+
* as the stack's `Proxmox.Role` would set it (role.ts: a declaration replaces the set).
|
|
21
|
+
* ★ ONE NODE PER CLUSTER. Users, groups, roles and ACLs live in `/etc/pve/user.cfg`, which pmxcfs
|
|
22
|
+
* shares across the cluster: a node joining a cluster that has the baseline needs nothing, and a
|
|
23
|
+
* run there is all `ok`. A new standalone node or a new cluster is where it creates.
|
|
24
|
+
* ⚠️ NAMES ARE NOT QUOTED INTO SAFETY, THEY ARE REFUSED INTO IT: provision-baseline.ts admits only
|
|
25
|
+
* characters that need no quoting in `sh` or in a Perl `q{}`.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
type ProvisionBaseline,
|
|
29
|
+
type ProvisionNames,
|
|
30
|
+
provisionBaseline,
|
|
31
|
+
} from './provision-baseline.ts';
|
|
32
|
+
|
|
33
|
+
const HEADER = `#!/bin/sh
|
|
34
|
+
# Provisioning baseline bootstrap, generated by @homeflare/alchemy (provisionBootstrap).
|
|
35
|
+
# Run ONCE, as root, on one node of a NEW cluster (or a new standalone node). Every step reads
|
|
36
|
+
# first and writes only what differs; a second run changes nothing. No token, no password.
|
|
37
|
+
set -eu
|
|
38
|
+
[ "$(id -u)" -eq 0 ] || { echo 'bootstrap: run this as root' >&2; exit 1; }
|
|
39
|
+
command -v pvesh >/dev/null 2>&1 || { echo 'bootstrap: no pvesh; run this on a PVE node' >&2; exit 1; }
|
|
40
|
+
perl -MJSON::PP -e 1 || { echo 'bootstrap: perl cannot load JSON::PP' >&2; exit 1; }
|
|
41
|
+
|
|
42
|
+
# get <api-path> <perl>: print <perl> evaluated over the object at <api-path> (as $d).
|
|
43
|
+
# Fails, silently, when the object does not exist: the empty input is not JSON.
|
|
44
|
+
get() {
|
|
45
|
+
pvesh get "$1" --output-format json 2>/dev/null | perl -MJSON::PP -0777 -ne "\\$d = decode_json(\\$_); $2" 2>/dev/null
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/** A Perl string literal with no interpolation — `@pve` in a double-quoted one is an array. */
|
|
50
|
+
const perlText = (text: string) => `q{${text}}`;
|
|
51
|
+
|
|
52
|
+
/** The user fields `Proxmox.User` compares, as one line both sides can produce. */
|
|
53
|
+
const USER_STATE =
|
|
54
|
+
'my $g = $d->{groups} // []; $g = [split /,/, $g] unless ref $g; ' +
|
|
55
|
+
'print join(";", "groups=" . join(",", sort @$g), "enable=" . (($d->{enable} // 1) ? 1 : 0), ' +
|
|
56
|
+
'"expire=" . ($d->{expire} // 0), "email=" . ($d->{email} // ""), ' +
|
|
57
|
+
'"firstname=" . ($d->{firstname} // ""), "lastname=" . ($d->{lastname} // ""), ' +
|
|
58
|
+
'"comment=" . ($d->{comment} // ""))';
|
|
59
|
+
|
|
60
|
+
const roleStep = ({ role }: ProvisionBaseline): string => {
|
|
61
|
+
const privs = [...role.privs].sort().join(',');
|
|
62
|
+
return `
|
|
63
|
+
# Role ${role.roleid}: exactly the provision privileges, on no path yet.
|
|
64
|
+
want='${privs}'
|
|
65
|
+
if have=$(get /access/roles/${role.roleid} 'print join(",", sort grep { /\\./ } keys %$d)'); then
|
|
66
|
+
if [ "$have" = "$want" ]; then echo 'role ${role.roleid}: ok'
|
|
67
|
+
else echo 'role ${role.roleid}: setting the baseline privileges'; pveum role modify ${role.roleid} --privs "$want"; fi
|
|
68
|
+
else
|
|
69
|
+
echo 'role ${role.roleid}: adding'; pveum role add ${role.roleid} --privs "$want"
|
|
70
|
+
fi
|
|
71
|
+
`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const groupStep = ({ group }: ProvisionBaseline): string => `
|
|
75
|
+
# Group ${group.groupid}: OpenBao may mint only for its members.
|
|
76
|
+
if have=$(get /access/groups/${group.groupid} 'print $d->{comment} // ""'); then
|
|
77
|
+
if [ "$have" = '${group.comment}' ]; then echo 'group ${group.groupid}: ok'
|
|
78
|
+
else echo 'group ${group.groupid}: setting the comment'; pveum group modify ${group.groupid} --comment '${group.comment}'; fi
|
|
79
|
+
else
|
|
80
|
+
echo 'group ${group.groupid}: adding'; pveum group add ${group.groupid} --comment '${group.comment}'
|
|
81
|
+
fi
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const userStep = (user: ProvisionBaseline['users'][number]): string => {
|
|
85
|
+
const groups = [...user.groups].sort().join(',');
|
|
86
|
+
const want = `groups=${groups};enable=1;expire=0;email=;firstname=;lastname=;comment=${user.comment}`;
|
|
87
|
+
const fields = `--groups ${groups} --comment '${user.comment}'`;
|
|
88
|
+
return `
|
|
89
|
+
# User ${user.userid} (the ${user.lane} lane): in ${groups}, enabled, no password.
|
|
90
|
+
if have=$(get /access/users/${user.userid} '${USER_STATE}'); then
|
|
91
|
+
if [ "$have" = '${want}' ]; then echo 'user ${user.userid}: ok'
|
|
92
|
+
else echo 'user ${user.userid}: setting groups, comment and account fields'
|
|
93
|
+
pveum user modify ${user.userid} ${fields} --enable 1 --expire 0 --email '' --firstname '' --lastname ''; fi
|
|
94
|
+
else
|
|
95
|
+
echo 'user ${user.userid}: adding'; pveum user add ${user.userid} ${fields}
|
|
96
|
+
fi
|
|
97
|
+
`;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const grantStep = (grant: ProvisionBaseline['grants'][number]): string => {
|
|
101
|
+
const match =
|
|
102
|
+
`print scalar grep { $_->{path} eq "/" && $_->{type} eq "user" && ` +
|
|
103
|
+
`$_->{ugid} eq ${perlText(grant.userid)} && $_->{roleid} eq ${perlText(grant.roleid)} && ` +
|
|
104
|
+
`($_->{propagate} // 1) } @$d`;
|
|
105
|
+
return `
|
|
106
|
+
# Grant: ${grant.userid} holds ${grant.roleid} on /, propagated (the ${grant.lane} lane).
|
|
107
|
+
if [ "$(get /access/acl '${match}')" = 1 ]; then echo 'grant ${grant.userid} ${grant.roleid} /: ok'
|
|
108
|
+
else echo 'grant ${grant.userid} ${grant.roleid} /: adding'; pveum acl modify / --users ${grant.userid} --roles ${grant.roleid} --propagate 1; fi
|
|
109
|
+
`;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** A role the baseline binds but does not own — the read lane's — must already exist. */
|
|
113
|
+
const requireRole = (roleid: string): string => `
|
|
114
|
+
get /access/roles/${roleid} 'print 1' >/dev/null || { echo 'bootstrap: role ${roleid} does not exist; create it, or name another readRole' >&2; exit 1; }
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The script for `names` (defaults: `PROVISION_DEFAULTS`). Pure: it reads nothing and runs nothing.
|
|
119
|
+
* Throws, like `provisionBaseline`, on a name the baseline refuses.
|
|
120
|
+
*/
|
|
121
|
+
export const provisionBootstrap = (names: ProvisionNames = {}): string => {
|
|
122
|
+
const baseline = provisionBaseline(names);
|
|
123
|
+
const borrowed = baseline.grants
|
|
124
|
+
.map((grant) => grant.roleid)
|
|
125
|
+
.filter((roleid) => roleid !== baseline.role.roleid);
|
|
126
|
+
return [
|
|
127
|
+
HEADER,
|
|
128
|
+
...[...new Set(borrowed)].map(requireRole),
|
|
129
|
+
roleStep(baseline),
|
|
130
|
+
groupStep(baseline),
|
|
131
|
+
...baseline.users.map(userStep),
|
|
132
|
+
...baseline.grants.map(grantStep),
|
|
133
|
+
`\necho 'provision baseline: done. Next: point the OpenBao proxmox mount at these users, then deploy the stack that declares this baseline with --adopt.'\n`,
|
|
134
|
+
].join('');
|
|
135
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake `pvesh` / `pveum` / `id` for the bootstrap tests: the generated script runs for real, under
|
|
3
|
+
* `sh`, with real Perl parsing the JSON, against an access config held in one JSON file — so what is
|
|
4
|
+
* tested is the script a person pipes to a node, not a model of it.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY. No provider imports this file. Run as a program (`import.meta.main`), it IS the fake
|
|
7
|
+
* CLI; imported, it is the harness that writes the wrappers and runs a script against them.
|
|
8
|
+
* ★ IT REFUSES WHAT PVE REFUSES WHERE ORDER MATTERS: adding an object that exists, modifying one
|
|
9
|
+
* that does not, a user in a group that does not exist, a grant of a role that does not exist.
|
|
10
|
+
* A script that runs its steps in the wrong order fails here as it would on a node.
|
|
11
|
+
* ★ READS ANSWER IN THE ITEM SHAPES access/*.ts DOCUMENT: a role is a privilege map, a user's
|
|
12
|
+
* groups an array, empty strings omitted. `apiView` is also what the engine test's fake PVE
|
|
13
|
+
* serves, so the bootstrap's result and the declaration are compared on one picture.
|
|
14
|
+
*/
|
|
15
|
+
import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export type CliUser = {
|
|
20
|
+
comment: string;
|
|
21
|
+
email: string;
|
|
22
|
+
enable: number;
|
|
23
|
+
expire: number;
|
|
24
|
+
firstname: string;
|
|
25
|
+
lastname: string;
|
|
26
|
+
groups: string[];
|
|
27
|
+
};
|
|
28
|
+
export type CliGrant = {
|
|
29
|
+
path: string;
|
|
30
|
+
type: string;
|
|
31
|
+
ugid: string;
|
|
32
|
+
roleid: string;
|
|
33
|
+
propagate: number;
|
|
34
|
+
};
|
|
35
|
+
export type CliState = {
|
|
36
|
+
roles: Record<string, string[]>;
|
|
37
|
+
groups: Record<string, { comment: string }>;
|
|
38
|
+
users: Record<string, CliUser>;
|
|
39
|
+
acl: CliGrant[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A cluster as PVE installs it: the built-in auditor role, and nothing of the baseline. */
|
|
43
|
+
export const freshCluster = (): CliState => ({
|
|
44
|
+
acl: [],
|
|
45
|
+
groups: {},
|
|
46
|
+
roles: {
|
|
47
|
+
PVEAuditor: [
|
|
48
|
+
'Datastore.Audit',
|
|
49
|
+
'Mapping.Audit',
|
|
50
|
+
'Pool.Audit',
|
|
51
|
+
'SDN.Audit',
|
|
52
|
+
'Sys.Audit',
|
|
53
|
+
'VM.Audit',
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
users: {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const text = (key: string, value: string) => (value === '' ? {} : { [key]: value });
|
|
60
|
+
|
|
61
|
+
/** Every item GET the scripts and the providers make, keyed `access/…` without a leading slash. */
|
|
62
|
+
export const apiView = (state: CliState): Record<string, unknown> => {
|
|
63
|
+
const view: Record<string, unknown> = { 'access/acl': state.acl };
|
|
64
|
+
for (const [id, privs] of Object.entries(state.roles)) {
|
|
65
|
+
view[`access/roles/${id}`] = Object.fromEntries(privs.map((priv) => [priv, 1]));
|
|
66
|
+
}
|
|
67
|
+
for (const [id, group] of Object.entries(state.groups)) {
|
|
68
|
+
const members = Object.keys(state.users).filter((u) => state.users[u]?.groups.includes(id));
|
|
69
|
+
view[`access/groups/${id}`] = { ...text('comment', group.comment), members };
|
|
70
|
+
}
|
|
71
|
+
for (const [id, user] of Object.entries(state.users)) {
|
|
72
|
+
view[`access/users/${id}`] = {
|
|
73
|
+
...text('comment', user.comment),
|
|
74
|
+
...text('email', user.email),
|
|
75
|
+
...text('firstname', user.firstname),
|
|
76
|
+
...text('lastname', user.lastname),
|
|
77
|
+
enable: user.enable,
|
|
78
|
+
expire: user.expire,
|
|
79
|
+
groups: user.groups,
|
|
80
|
+
tokens: {},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return view;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const flags = (args: string[]) => {
|
|
87
|
+
const out: Record<string, string> = {};
|
|
88
|
+
for (let i = 0; i < args.length; i += 2)
|
|
89
|
+
out[(args[i] ?? '').replace(/^--/, '')] = args[i + 1] ?? '';
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** One pveum call against `state`; returns the refusal PVE would print, or undefined. */
|
|
94
|
+
const pveum = (state: CliState, [kind, verb, id = '', ...rest]: string[]): string | undefined => {
|
|
95
|
+
const f = flags(rest);
|
|
96
|
+
const list = (csv: string | undefined) => (csv ?? '').split(',').filter((s) => s !== '');
|
|
97
|
+
if (kind === 'role') {
|
|
98
|
+
if ((verb === 'add') === id in state.roles) return `role '${id}': wrong state for ${verb}`;
|
|
99
|
+
state.roles[id] = list(f['privs']);
|
|
100
|
+
} else if (kind === 'group') {
|
|
101
|
+
if ((verb === 'add') === id in state.groups) return `group '${id}': wrong state for ${verb}`;
|
|
102
|
+
state.groups[id] = { comment: f['comment'] ?? '' };
|
|
103
|
+
} else if (kind === 'user') {
|
|
104
|
+
const had = state.users[id];
|
|
105
|
+
if ((verb === 'add') === (had !== undefined)) return `user '${id}': wrong state for ${verb}`;
|
|
106
|
+
const groups = f['groups'] === undefined ? (had?.groups ?? []) : list(f['groups']);
|
|
107
|
+
const missing = groups.find((g) => !(g in state.groups));
|
|
108
|
+
if (missing !== undefined) return `no such group '${missing}'`;
|
|
109
|
+
const base: CliUser = had ?? {
|
|
110
|
+
comment: '',
|
|
111
|
+
email: '',
|
|
112
|
+
enable: 1,
|
|
113
|
+
expire: 0,
|
|
114
|
+
firstname: '',
|
|
115
|
+
lastname: '',
|
|
116
|
+
groups: [],
|
|
117
|
+
};
|
|
118
|
+
state.users[id] = {
|
|
119
|
+
comment: f['comment'] ?? base.comment,
|
|
120
|
+
email: f['email'] ?? base.email,
|
|
121
|
+
enable: f['enable'] === undefined ? base.enable : Number(f['enable']),
|
|
122
|
+
expire: f['expire'] === undefined ? base.expire : Number(f['expire']),
|
|
123
|
+
firstname: f['firstname'] ?? base.firstname,
|
|
124
|
+
groups,
|
|
125
|
+
lastname: f['lastname'] ?? base.lastname,
|
|
126
|
+
};
|
|
127
|
+
} else if (kind === 'acl' && verb === 'modify') {
|
|
128
|
+
const roleid = f['roles'] ?? '';
|
|
129
|
+
if (!(roleid in state.roles)) return `role '${roleid}' does not exist`;
|
|
130
|
+
const grant = {
|
|
131
|
+
path: id,
|
|
132
|
+
propagate: Number(f['propagate'] ?? 1),
|
|
133
|
+
roleid,
|
|
134
|
+
type: 'user',
|
|
135
|
+
ugid: f['users'] ?? '',
|
|
136
|
+
};
|
|
137
|
+
state.acl = state.acl.filter(
|
|
138
|
+
(g) => !(g.path === id && g.ugid === grant.ugid && g.roleid === roleid),
|
|
139
|
+
);
|
|
140
|
+
state.acl.push(grant);
|
|
141
|
+
} else return `unknown pveum call: ${kind} ${verb}`;
|
|
142
|
+
return undefined;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The fake CLI's entry point: `<tool> <args…>`, with FAKE_PVE_STATE and FAKE_PVE_LOG set. */
|
|
146
|
+
const runCli = ([tool, ...args]: string[]): number => {
|
|
147
|
+
if (tool === 'id') {
|
|
148
|
+
process.stdout.write('0\n');
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const file = process.env['FAKE_PVE_STATE'] ?? '';
|
|
152
|
+
const state = JSON.parse(readFileSync(file, 'utf8')) as CliState;
|
|
153
|
+
if (tool === 'pvesh') {
|
|
154
|
+
const found = apiView(state)[(args[1] ?? '').replace(/^\//, '')];
|
|
155
|
+
if (found === undefined) {
|
|
156
|
+
process.stderr.write(`no such object ${args[1] ?? ''}\n`);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
process.stdout.write(JSON.stringify(found));
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
appendFileSync(process.env['FAKE_PVE_LOG'] ?? '', `pveum ${args.join(' ')}\n`);
|
|
163
|
+
const refused = pveum(state, args);
|
|
164
|
+
if (refused !== undefined) {
|
|
165
|
+
process.stderr.write(`${refused}\n`);
|
|
166
|
+
return 255;
|
|
167
|
+
}
|
|
168
|
+
writeFileSync(file, JSON.stringify(state));
|
|
169
|
+
return 0;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (import.meta.main) process.exit(runCli(process.argv.slice(2)));
|
|
173
|
+
|
|
174
|
+
export type ScriptRun = {
|
|
175
|
+
code: number;
|
|
176
|
+
stdout: string;
|
|
177
|
+
stderr: string;
|
|
178
|
+
writes: string[];
|
|
179
|
+
state: CliState;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Run `script` under `sh` against `state`, with the fakes first on PATH. */
|
|
183
|
+
export const runScript = async (script: string, state: CliState): Promise<ScriptRun> => {
|
|
184
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-bootstrap-'));
|
|
185
|
+
for (const tool of ['id', 'pvesh', 'pveum']) {
|
|
186
|
+
const wrapper = `#!/bin/sh\nexec '${process.execPath}' '${import.meta.path}' ${tool} "$@"\n`;
|
|
187
|
+
writeFileSync(join(dir, tool), wrapper, { mode: 0o755 });
|
|
188
|
+
}
|
|
189
|
+
const stateFile = join(dir, 'state.json');
|
|
190
|
+
const log = join(dir, 'pveum.log');
|
|
191
|
+
writeFileSync(stateFile, JSON.stringify(state));
|
|
192
|
+
writeFileSync(log, '');
|
|
193
|
+
try {
|
|
194
|
+
const child = Bun.spawn(['sh', '-s'], {
|
|
195
|
+
env: {
|
|
196
|
+
FAKE_PVE_LOG: log,
|
|
197
|
+
FAKE_PVE_STATE: stateFile,
|
|
198
|
+
PATH: `${dir}:${process.env['PATH'] ?? ''}`,
|
|
199
|
+
},
|
|
200
|
+
stderr: 'pipe',
|
|
201
|
+
stdin: new TextEncoder().encode(script),
|
|
202
|
+
stdout: 'pipe',
|
|
203
|
+
});
|
|
204
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
205
|
+
new Response(child.stdout).text(),
|
|
206
|
+
new Response(child.stderr).text(),
|
|
207
|
+
child.exited,
|
|
208
|
+
]);
|
|
209
|
+
const writes = readFileSync(log, 'utf8')
|
|
210
|
+
.split('\n')
|
|
211
|
+
.filter((line) => line !== '');
|
|
212
|
+
const after = JSON.parse(readFileSync(stateFile, 'utf8')) as CliState;
|
|
213
|
+
return { code, state: after, stderr, stdout, writes };
|
|
214
|
+
} finally {
|
|
215
|
+
rmSync(dir, { force: true, recursive: true });
|
|
216
|
+
}
|
|
217
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareProvisionBaseline` through Alchemy's real plan and apply, over a fake PVE serving what the
|
|
3
|
+
* generated bootstrap made (provision-cli-fake.ts `apiView`) — the proof that the two halves are
|
|
4
|
+
* one baseline: after the bootstrap, the declaration adopts every object and writes NOTHING.
|
|
5
|
+
* Also pinned: a hand edit shows up as a diff; every resource retains; `adopt` is piped only when
|
|
6
|
+
* asked; and a refused name fails before anything is declared.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { push } from 'alchemy';
|
|
10
|
+
import { Stack } from 'alchemy/Stack';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
14
|
+
import { ProxmoxAclProvider } from './acl.ts';
|
|
15
|
+
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
16
|
+
import { ProxmoxGroupProvider } from './group.ts';
|
|
17
|
+
import { type CliState, apiView, freshCluster, runScript } from './provision-cli-fake.ts';
|
|
18
|
+
import { provisionBootstrap } from './provision-bootstrap.ts';
|
|
19
|
+
import { type DeclareProvisionOptions, declareProvisionBaseline } from './provision-declare.ts';
|
|
20
|
+
import { ProxmoxRoleProvider } from './role.ts';
|
|
21
|
+
import { ProxmoxUserProvider } from './user.ts';
|
|
22
|
+
|
|
23
|
+
const ALL = [
|
|
24
|
+
'pve-role',
|
|
25
|
+
'pve-mint-group',
|
|
26
|
+
'pve-provision-user',
|
|
27
|
+
'pve-read-user',
|
|
28
|
+
'pve-provision-grant',
|
|
29
|
+
'pve-read-grant',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const bootstrapped = async (): Promise<CliState> => {
|
|
33
|
+
const run = await runScript(provisionBootstrap(), freshCluster());
|
|
34
|
+
if (run.code !== 0) throw new Error(`bootstrap failed: ${run.stderr}`);
|
|
35
|
+
return run.state;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const engineOn = (state: CliState) => {
|
|
39
|
+
const live = apiView(state);
|
|
40
|
+
const fake = fakePve((call) => (call.method === 'GET' ? live[call.path] : 'UPID:fake'));
|
|
41
|
+
const providers = Layer.mergeAll(
|
|
42
|
+
ProxmoxAclProvider(),
|
|
43
|
+
ProxmoxGroupProvider(),
|
|
44
|
+
ProxmoxRoleProvider(),
|
|
45
|
+
ProxmoxUserProvider(),
|
|
46
|
+
);
|
|
47
|
+
return { engine: engineOver(providers.pipe(Layer.provideMerge(fake.layer))), fake };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe('after the bootstrap, the declaration is a clean adoption', () => {
|
|
51
|
+
test('every object is adopted, and nothing is written', async () => {
|
|
52
|
+
const { engine, fake } = engineOn(await bootstrapped());
|
|
53
|
+
await withoutBao(async () => {
|
|
54
|
+
const report = await engine.verify(declareProvisionBaseline('pve', FAKE_TARGET));
|
|
55
|
+
expect(report.rows.map((row) => [row.fqn, row.diff, row.ok])).toEqual(
|
|
56
|
+
[...ALL].sort().map((fqn) => [fqn, 'noop', true]),
|
|
57
|
+
);
|
|
58
|
+
const deployed = await engine.deploy(declareProvisionBaseline('pve', FAKE_TARGET));
|
|
59
|
+
expect(deployed).toEqual(Object.fromEntries(ALL.map((fqn) => [fqn, 'adopted'])));
|
|
60
|
+
});
|
|
61
|
+
expect(fake.writes()).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a privilege added by hand is a diff on the role, and nothing else', async () => {
|
|
65
|
+
const state = await bootstrapped();
|
|
66
|
+
state.roles['HfProvisioner']?.push('VM.Console');
|
|
67
|
+
const { engine } = engineOn(state);
|
|
68
|
+
await withoutBao(async () => {
|
|
69
|
+
const report = await engine.verify(declareProvisionBaseline('pve', FAKE_TARGET));
|
|
70
|
+
const changed = report.rows.filter((row) => row.diff !== 'noop');
|
|
71
|
+
expect(changed).toEqual([expect.objectContaining({ changed: ['privs'], fqn: 'pve-role' })]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Register the declaration under a bare Stack and read back what Alchemy recorded per resource. */
|
|
77
|
+
const registered = async (options?: DeclareProvisionOptions) => {
|
|
78
|
+
const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
|
|
79
|
+
await Effect.runPromise(
|
|
80
|
+
push('lab', declareProvisionBaseline('pve', FAKE_TARGET, {}, options)).pipe(
|
|
81
|
+
Effect.provideService(Stack, spec as never),
|
|
82
|
+
) as Effect.Effect<unknown>,
|
|
83
|
+
);
|
|
84
|
+
return spec.resources as Record<string, { Adopt?: boolean; RemovalPolicy?: string }>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe('what the helper registers', () => {
|
|
88
|
+
test('every resource retains, whatever the family default', async () => {
|
|
89
|
+
const resources = await registered();
|
|
90
|
+
expect(Object.keys(resources).sort()).toEqual(ALL.map((id) => `lab/${id}`).sort());
|
|
91
|
+
for (const each of Object.values(resources)) expect(each.RemovalPolicy).toBe('retain');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test.each([
|
|
95
|
+
[{ adopt: true }, true],
|
|
96
|
+
[{ adopt: false }, false],
|
|
97
|
+
[{}, undefined],
|
|
98
|
+
] as const)('adopt %o is recorded as %p on every resource', async (options, expected) => {
|
|
99
|
+
for (const each of Object.values(await registered(options))) expect(each.Adopt).toBe(expected);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a refused name fails before anything is declared', async () => {
|
|
103
|
+
const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
|
|
104
|
+
const declared = declareProvisionBaseline('pve', FAKE_TARGET, { role: 'bad role' });
|
|
105
|
+
const exit = await Effect.runPromiseExit(
|
|
106
|
+
declared.pipe(Effect.provideService(Stack, spec as never)) as Effect.Effect<unknown, unknown>,
|
|
107
|
+
);
|
|
108
|
+
expect(String(exit)).toContain('role `bad role` is not a PVE role id');
|
|
109
|
+
expect(spec.resources).toEqual({});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareProvisionBaseline` — the provisioning baseline (provision-baseline.ts) as resources, so a
|
|
3
|
+
* stack adopts what the bootstrap made and keeps it: the role, the mint group, one user per lane
|
|
4
|
+
* (its group membership included) and the grant binding each user to its role on `/`.
|
|
5
|
+
*
|
|
6
|
+
* const lane = yield* declareProvisionBaseline('pve', target);
|
|
7
|
+
* // names are generic by default; a site passes its own:
|
|
8
|
+
* const lane = yield* declareProvisionBaseline('pve', target, { role: 'Provisioner' });
|
|
9
|
+
*
|
|
10
|
+
* Provide `ProxmoxRoleProvider()`, `ProxmoxGroupProvider()`, `ProxmoxUserProvider()` and
|
|
11
|
+
* `ProxmoxAclProvider()` with `FetchHttpClient.layer`.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ EVERY RESOURCE HERE RETAINS. This is the branch the whole package sits on (user.ts): dropping
|
|
14
|
+
* the call from a stack must drop state, never the role, the users or their grants — deleting the
|
|
15
|
+
* provision user revokes every lease at once, the plan that would recreate it included.
|
|
16
|
+
* ⛔ ADOPTION IS THE OPERATOR'S TO STATE, AS EVERYWHERE IN THE KIT (docs/ownership.md). The first
|
|
17
|
+
* deploy after the bootstrap finds all of this live with no state: run it with `--adopt`, or pass
|
|
18
|
+
* `{ adopt: true }`, which pipes `adopt(true)` onto each resource. `{ adopt: false }` pins them
|
|
19
|
+
* refused under a deploy-wide `--adopt`. Left out, the deploy's own policy decides.
|
|
20
|
+
* ⚠️ TODAY THE FOUR FAMILIES STILL READ A LIVE OBJECT WITH NO STATE AS OURS (the `pveHandlers`
|
|
21
|
+
* limit in ownership.md), so the flag is not yet what stops a claim. It is stated here so that
|
|
22
|
+
* when they follow the rule, a stack built on this helper already says what it means.
|
|
23
|
+
* ★ THE BOOTSTRAP MAKES EXACTLY THIS, so a clean adoption writes nothing: the same privileges, the
|
|
24
|
+
* same comment, the same single group per user (provision-bootstrap.ts reads the same baseline).
|
|
25
|
+
* ★ PLAIN NAMES, NOT ONE RESOURCE'S OUTPUT IN ANOTHER'S PROPS. An Output would order the creates,
|
|
26
|
+
* but Alchemy skips the adoption probe while any prop is one (Plan.ts), so the first deploy after
|
|
27
|
+
* the bootstrap would plan `create` for objects that are all there. Order never matters here:
|
|
28
|
+
* nothing in this package can run before the bootstrap, and after it everything exists.
|
|
29
|
+
*/
|
|
30
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
31
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import { ProxmoxAcl } from './acl.ts';
|
|
34
|
+
import type { PveTarget } from './credentials.ts';
|
|
35
|
+
import { ProxmoxGroup } from './group.ts';
|
|
36
|
+
import { type ProvisionNames, provisionBaseline } from './provision-baseline.ts';
|
|
37
|
+
import { ProxmoxRole } from './role.ts';
|
|
38
|
+
import { ProxmoxUser } from './user.ts';
|
|
39
|
+
|
|
40
|
+
export interface DeclareProvisionOptions {
|
|
41
|
+
/** `true`/`false` pipe `adopt(…)` onto every resource; omitted, the deploy's policy decides. */
|
|
42
|
+
readonly adopt?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The baseline for one cluster. `id` prefixes every logical id (`<id>-role`, `<id>-mint-group`,
|
|
47
|
+
* `<id>-provision-user`, `<id>-provision-grant`, and the `read` pair), so one stack can declare a
|
|
48
|
+
* baseline per cluster. `users` and `grants` come back in `baseline.users` / `baseline.grants`
|
|
49
|
+
* order: provision, then read. Fails, before declaring anything, on a name the baseline refuses.
|
|
50
|
+
*/
|
|
51
|
+
export const declareProvisionBaseline = (
|
|
52
|
+
id: string,
|
|
53
|
+
target: PveTarget,
|
|
54
|
+
names: ProvisionNames = {},
|
|
55
|
+
options: DeclareProvisionOptions = {},
|
|
56
|
+
) =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const baseline = yield* Effect.try({
|
|
59
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
60
|
+
try: () => provisionBaseline(names),
|
|
61
|
+
});
|
|
62
|
+
const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {
|
|
63
|
+
const kept = resource.pipe(RemovalPolicy.retain());
|
|
64
|
+
return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
|
|
65
|
+
};
|
|
66
|
+
const role = yield* owned(
|
|
67
|
+
ProxmoxRole(`${id}-role`, {
|
|
68
|
+
privs: [...baseline.role.privs],
|
|
69
|
+
roleid: baseline.role.roleid,
|
|
70
|
+
target,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
const group = yield* owned(ProxmoxGroup(`${id}-mint-group`, { ...baseline.group, target }));
|
|
74
|
+
// ★ `Effect.all` over mapped declarations (sequential), as cloudflare-permission-groups.ts does:
|
|
75
|
+
// oxlint's unicorn/no-array-for-each cannot tell `Effect.forEach` from `Array#forEach`.
|
|
76
|
+
const users = yield* Effect.all(
|
|
77
|
+
baseline.users.map((user) =>
|
|
78
|
+
owned(
|
|
79
|
+
ProxmoxUser(`${id}-${user.lane}-user`, {
|
|
80
|
+
comment: user.comment,
|
|
81
|
+
groups: [...user.groups],
|
|
82
|
+
target,
|
|
83
|
+
userid: user.userid,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
const grants = yield* Effect.all(
|
|
89
|
+
baseline.grants.map((grant) =>
|
|
90
|
+
owned(
|
|
91
|
+
ProxmoxAcl(`${id}-${grant.lane}-grant`, {
|
|
92
|
+
path: '/',
|
|
93
|
+
propagate: true,
|
|
94
|
+
roleid: grant.roleid,
|
|
95
|
+
target,
|
|
96
|
+
type: 'user',
|
|
97
|
+
ugid: grant.userid,
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
return { baseline, grants, group, role, users };
|
|
103
|
+
});
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Proxmox.ReplicationJob` — a guest's ZFS storage replication to a second node, declared.
|
|
3
3
|
*
|
|
4
|
-
* ⛔ NOTHING ON
|
|
4
|
+
* ⛔ NOTHING ON C1 CAN USE THIS FAMILY, AND THAT IS SAID HERE RATHER THAN FOUND OUT LATER. PVE
|
|
5
5
|
* storage replication is ZFS-only: `POST /cluster/replication` calls `get_replicatable_volumes`
|
|
6
|
-
* and refuses a guest whose disks are anywhere else with "No replicatable volumes found".
|
|
6
|
+
* and refuses a guest whose disks are anywhere else with "No replicatable volumes found". C1 is
|
|
7
7
|
* Ceph/RBD-backed — shared storage, where replication means nothing because every node already
|
|
8
|
-
* sees the volume — so `GET /cluster/replication` answers `[]` (MEASURED on
|
|
9
|
-
* on this cluster always will. The file is written for a ZFS cluster; against
|
|
8
|
+
* sees the volume — so `GET /cluster/replication` answers `[]` (MEASURED on node-b, 2026-09-13) and
|
|
9
|
+
* on this cluster always will. The file is written for a ZFS cluster; against C1 a declaration
|
|
10
10
|
* is a create that fails with PVE's own sentence, not a plan that quietly does nothing.
|
|
11
11
|
*
|
|
12
|
-
* ★ WHAT IS MEASURED HERE AND WHAT IS REASONED. Measured on
|
|
12
|
+
* ★ WHAT IS MEASURED HERE AND WHAT IS REASONED. Measured on node-b (pve-manager 9.2.11), read-only:
|
|
13
13
|
* the published schema for all five methods; `PVE/ReplicationConfig.pm` and
|
|
14
14
|
* `PVE/API2/ReplicationConfig.pm` line by line; `GET /cluster/replication` -> `[]`; and the ACL
|
|
15
15
|
* row `{"path":"/","propagate":1,"roleid":"PVEAuditor","ugid":"hf-read@pve"}`. NOT measured: any
|
|
@@ -45,12 +45,13 @@
|
|
|
45
45
|
* declarations differing only in `jobnum` but naming the same target are not two jobs; the second
|
|
46
46
|
* is a create that fails.
|
|
47
47
|
*
|
|
48
|
-
* ⚠️ RECONCILE NEEDS `VM.Replicate` ON `/vms/<guest>`, WHICH
|
|
48
|
+
* ⚠️ RECONCILE NEEDS `VM.Replicate` ON `/vms/<guest>`, WHICH THE PROVISION ROLE DOES NOT HOLD. All
|
|
49
49
|
* three writes check it (MEASURED in API2/ReplicationConfig.pm: create, update and delete each
|
|
50
50
|
* call `$rpcenv->check($authuser, "/vms/$vmid", ['VM.Replicate'])`), and the role's 27 privileges
|
|
51
51
|
* include `VM.Allocate`, `VM.Audit`, `VM.Backup`, `VM.Config.*` and `VM.PowerMgmt` but not
|
|
52
|
-
* `VM.Replicate
|
|
53
|
-
*
|
|
52
|
+
* `VM.Replicate` (`PROVISION_PRIVILEGES`). Do NOT widen it by hand: the baseline declares the
|
|
53
|
+
* role's exact set, so a hand-added privilege is removed on the next deploy. Grant a second role
|
|
54
|
+
* holding `VM.Replicate` on `/vms/<guest>` instead.
|
|
54
55
|
* ★ THE READ LANE NEEDS NOTHING EXTRA, which is why there is no `readRole` below. The ITEM read
|
|
55
56
|
* checks `VM.Audit` on `/vms/<guest>`, `hf-read@pve` holds the built-in `PVEAuditor` at `/`
|
|
56
57
|
* with propagate — both MEASURED — and `PVEAuditor` carries `VM.Audit`. This family is not a
|
package/src/proxmox/resource.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
20
20
|
import type { Input } from 'alchemy/Input';
|
|
21
21
|
import * as Effect from 'effect/Effect';
|
|
22
22
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
|
-
import { pve } from './client.ts';
|
|
23
|
+
import { type PveForm, pve } from './client.ts';
|
|
24
24
|
import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
|
|
25
25
|
import { formToSend } from './update-guard.ts';
|
|
26
26
|
|
|
@@ -71,21 +71,21 @@ export type WithPbsTarget = { target: PbsTarget };
|
|
|
71
71
|
export type WithApiTarget = { target: ApiTarget };
|
|
72
72
|
|
|
73
73
|
export type PveSpec<Props extends WithApiTarget, Attributes> = {
|
|
74
|
-
/** `pools/
|
|
74
|
+
/** `pools/lab`, `nodes/node-b/lxc/101` — where ONE object is read, updated and deleted. */
|
|
75
75
|
readonly path: (props: Props) => string;
|
|
76
|
-
/** `pools`, `nodes/
|
|
76
|
+
/** `pools`, `nodes/node-b/lxc` — where a NEW one is POSTed. */
|
|
77
77
|
readonly collection: (props: Props) => string;
|
|
78
78
|
/** Live JSON to attributes. Returning undefined means "this is not really there". */
|
|
79
79
|
readonly attributes: (live: Record<string, unknown>, props: Props) => Attributes | undefined;
|
|
80
|
-
/** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. */
|
|
81
|
-
readonly createForm: (props: Props) =>
|
|
80
|
+
/** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. Arrays: client.ts. */
|
|
81
|
+
readonly createForm: (props: Props) => PveForm;
|
|
82
82
|
/**
|
|
83
83
|
* The form for an update, or undefined when the object has no mutable fields.
|
|
84
84
|
*
|
|
85
85
|
* ⚠️ SOME PVE OBJECTS CANNOT BE UPDATED AT ALL. Returning undefined makes a changed prop a
|
|
86
86
|
* REPLACE rather than a silent no-op, which is the honest answer for an immutable object.
|
|
87
87
|
*/
|
|
88
|
-
readonly updateForm?: (props: Props) =>
|
|
88
|
+
readonly updateForm?: (props: Props) => PveForm;
|
|
89
89
|
/** True when live already matches props. Decides noop vs update. */
|
|
90
90
|
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
91
91
|
/**
|
|
@@ -105,7 +105,7 @@ export type PveSpec<Props extends WithApiTarget, Attributes> = {
|
|
|
105
105
|
export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
106
106
|
spec: PveSpec<Props, Attributes>,
|
|
107
107
|
) => {
|
|
108
|
-
/** The live object, or undefined. ⚠️ A 404
|
|
108
|
+
/** The live object, or undefined. ⚠️ A 404 and `{"data": null}` are ANSWERS, not failures. */
|
|
109
109
|
const read = (props: Props) =>
|
|
110
110
|
pve<Record<string, unknown>>(
|
|
111
111
|
props.target,
|
|
@@ -113,7 +113,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
|
113
113
|
'GET',
|
|
114
114
|
spec.path(props),
|
|
115
115
|
).pipe(
|
|
116
|
-
Effect.map((data) => (data
|
|
116
|
+
Effect.map((data) => (data == null ? undefined : spec.attributes(data, props))),
|
|
117
117
|
Effect.orElseSucceed(() => undefined),
|
|
118
118
|
);
|
|
119
119
|
|
|
@@ -164,7 +164,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
|
164
164
|
* `Proxmox.NodeNetwork` it is worse than pointless: a PUT under `/nodes/{node}/network`
|
|
165
165
|
* STAGES a change into `/etc/network/interfaces.new`, so merely adopting the interface
|
|
166
166
|
* a node already has would leave that node with a pending network change waiting for an
|
|
167
|
-
* apply — on a cluster whose Ceph traffic rides vmbr1.
|
|
167
|
+
* apply — on a cluster whose Ceph traffic rides vmbr1.42. Adoption has to be free.
|
|
168
168
|
*
|
|
169
169
|
* ★ SO THE CONDITION IS THE SAME PREDICATE `diff` USES. If `matches` is true the object
|
|
170
170
|
* already says what the declaration says, and there is nothing to write — by
|