@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The fabric comparison, which is what decides whether `Proxmox.SdnApply` publishes.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ IT IS TESTED HERE RATHER THAN AGAINST THE CLUSTER BECAUSE STAGING A REAL FABRIC CHANGE MEANS
|
|
5
|
-
* TOUCHING CEPH'S CLUSTER NETWORK.
|
|
5
|
+
* TOUCHING CEPH'S CLUSTER NETWORK. C1's OSPF fabric pins 203.0.113.102/103/104 to tb0 and tb1,
|
|
6
6
|
* and those are the addresses `cluster_network` runs over. The staged-zone path IS proven end to
|
|
7
7
|
* end against the live cluster (plan noop -> stage a zone -> plan update -> remove -> plan noop);
|
|
8
8
|
* the fabric path is proven by construction, with the two ways it could lie pinned below.
|
|
@@ -20,20 +20,20 @@ import { UNDIFFABLE, canonical, fabricStaged, subsystemAbsent } from './sdn-appl
|
|
|
20
20
|
|
|
21
21
|
/** The shape `cluster/sdn/fabrics/all` actually returns, trimmed to what matters. */
|
|
22
22
|
const N2 = {
|
|
23
|
-
fabric_id: '
|
|
24
|
-
interfaces: ['name=
|
|
25
|
-
ip: '
|
|
26
|
-
node_id: '
|
|
23
|
+
fabric_id: 'c1',
|
|
24
|
+
interfaces: ['name=tb0', 'name=tb1'],
|
|
25
|
+
ip: '203.0.113.102',
|
|
26
|
+
node_id: 'node-b',
|
|
27
27
|
};
|
|
28
28
|
const N3 = {
|
|
29
|
-
fabric_id: '
|
|
30
|
-
interfaces: ['name=
|
|
31
|
-
ip: '
|
|
32
|
-
node_id: '
|
|
29
|
+
fabric_id: 'c1',
|
|
30
|
+
interfaces: ['name=tb0', 'name=tb1'],
|
|
31
|
+
ip: '203.0.113.103',
|
|
32
|
+
node_id: 'node-c',
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
const running = {
|
|
36
|
-
fabrics: [{ area: '1', id: '
|
|
36
|
+
fabrics: [{ area: '1', id: 'c1', ip_prefix: '203.0.113.0/24', protocol: 'ospf' }],
|
|
37
37
|
nodes: [N2, N3],
|
|
38
38
|
};
|
|
39
39
|
|
|
@@ -44,8 +44,8 @@ describe('fabric canonicalisation', () => {
|
|
|
44
44
|
{
|
|
45
45
|
area: '1',
|
|
46
46
|
digest: '7b94d363ff69',
|
|
47
|
-
id: '
|
|
48
|
-
ip_prefix: '
|
|
47
|
+
id: 'c1',
|
|
48
|
+
ip_prefix: '203.0.113.0/24',
|
|
49
49
|
protocol: 'ospf',
|
|
50
50
|
},
|
|
51
51
|
],
|
|
@@ -76,7 +76,7 @@ describe('fabric canonicalisation', () => {
|
|
|
76
76
|
it('sees a changed node address', () => {
|
|
77
77
|
const moved = {
|
|
78
78
|
...running,
|
|
79
|
-
nodes: [{ ...N2, ip: '
|
|
79
|
+
nodes: [{ ...N2, ip: '203.0.113.199' }, N3],
|
|
80
80
|
};
|
|
81
81
|
assert.notEqual(canonical(moved), canonical(running));
|
|
82
82
|
});
|
|
@@ -86,7 +86,7 @@ describe('fabric canonicalisation', () => {
|
|
|
86
86
|
...running,
|
|
87
87
|
nodes: [
|
|
88
88
|
...running.nodes,
|
|
89
|
-
{ fabric_id: '
|
|
89
|
+
{ fabric_id: 'c1', interfaces: ['name=tb0'], ip: '203.0.113.104', node_id: 'node-d' },
|
|
90
90
|
],
|
|
91
91
|
};
|
|
92
92
|
assert.notEqual(canonical(added), canonical(running));
|
|
@@ -95,7 +95,7 @@ describe('fabric canonicalisation', () => {
|
|
|
95
95
|
it('sees a changed interface list, which is what decides where the address lands', () => {
|
|
96
96
|
const rewired = {
|
|
97
97
|
...running,
|
|
98
|
-
nodes: [{ ...N2, interfaces: ['name=
|
|
98
|
+
nodes: [{ ...N2, interfaces: ['name=tb0'] }, N3],
|
|
99
99
|
};
|
|
100
100
|
assert.notEqual(canonical(rewired), canonical(running));
|
|
101
101
|
});
|
|
@@ -135,7 +135,7 @@ describe('what a failed read is allowed to mean', () => {
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
it('counts an unchanged fabric as nothing staged and a moved node as staged', () => {
|
|
138
|
-
const moved = { ...running, nodes: [{ ...N2, ip: '
|
|
138
|
+
const moved = { ...running, nodes: [{ ...N2, ip: '203.0.113.199' }, N3] };
|
|
139
139
|
assert.equal(fabricStaged(canonical(running), canonical(running)), 0);
|
|
140
140
|
assert.equal(fabricStaged(canonical(moved), canonical(running)), 1);
|
|
141
141
|
});
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* collections this package declares resources for.
|
|
4
4
|
*
|
|
5
5
|
* 🔴 THE GAP THIS FILE CLOSES, AND IT WAS FOUND BY ALMOST DECLARING THE APPLY. `pendingCount` in
|
|
6
|
-
* sdn-apply.ts counted `cluster/sdn/zones` and `cluster/sdn/vnets`.
|
|
7
|
-
* staged OSPF FABRIC that nothing counted. MEASURED 2026-09-13 on
|
|
6
|
+
* sdn-apply.ts counted `cluster/sdn/zones` and `cluster/sdn/vnets`. C1 has zero of each — and a
|
|
7
|
+
* staged OSPF FABRIC that nothing counted. MEASURED 2026-09-13 on node-b:
|
|
8
8
|
*
|
|
9
|
-
* /etc/pve/sdn/fabrics.cfg ospf_fabric:
|
|
10
|
-
* ospf_node:
|
|
11
|
-
* ip
|
|
9
|
+
* /etc/pve/sdn/fabrics.cfg ospf_fabric: c1, area 1, ip_prefix 203.0.113.0/24
|
|
10
|
+
* ospf_node: c1_n2/node-c/node-d, interfaces tb0 + tb1,
|
|
11
|
+
* ip 203.0.113.102 / .103 / .104
|
|
12
12
|
* /etc/network/interfaces.d/sdn the file PVE GENERATES from it, carrying those /32s
|
|
13
13
|
*
|
|
14
14
|
* Those addresses are Ceph's `cluster_network`. So `diff` would have answered `noop` for ever
|
|
@@ -62,7 +62,7 @@ const MARKED = [
|
|
|
62
62
|
*
|
|
63
63
|
* ⚠️ NAMED RATHER THAN SILENTLY SKIPPED. `ipams` and `dns` answer HTTP 400 to both `?pending=1` and
|
|
64
64
|
* `?running=1`, so there is no way to ask them what is staged. They are a REAL blind spot: an
|
|
65
|
-
* ipam edited by hand is published by the apply and cannot be seen from here.
|
|
65
|
+
* ipam edited by hand is published by the apply and cannot be seen from here. C1 has one ipam
|
|
66
66
|
* (`pve`, the built-in) and zero dns entries, so the blind spot is currently empty — which is a
|
|
67
67
|
* fact about today, not a property of the design.
|
|
68
68
|
*/
|
|
@@ -181,7 +181,7 @@ const sortDeep = (value: unknown): unknown => {
|
|
|
181
181
|
*
|
|
182
182
|
* ⛔ ZERO HERE IS THE ONLY THING THAT MAKES DECLARING THE APPLY SAFE. `reconcile` runs on CREATE —
|
|
183
183
|
* Alchemy always calls it the first time a resource appears — and `PUT /cluster/sdn` regenerates
|
|
184
|
-
* `/etc/network/interfaces.d/sdn` on every node. On
|
|
184
|
+
* `/etc/network/interfaces.d/sdn` on every node. On C1 that file carries Ceph's cluster network.
|
|
185
185
|
*/
|
|
186
186
|
export const sdnPendingCount = (target: PveTarget) =>
|
|
187
187
|
Effect.all([markedPending(target), fabricPending(target)], { concurrency: 'unbounded' }).pipe(
|
package/src/proxmox/sdn-apply.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* is the apply, and it is CLUSTER-WIDE — it publishes every staged object at once, not the one
|
|
7
7
|
* that happened to reconcile.
|
|
8
8
|
*
|
|
9
|
-
* ★ MEASURED ON
|
|
9
|
+
* ★ MEASURED ON C1 ON 2026-09-13, NOT INFERRED. A probe zone was created, inspected and removed:
|
|
10
10
|
*
|
|
11
11
|
* pvesh create /cluster/sdn/zones --zone zzprobe --type simple
|
|
12
12
|
* GET /cluster/sdn/zones/zzprobe -> {"digest":"c438…","type":"simple","zone":"zzprobe"}
|
|
@@ -109,8 +109,8 @@ export const ProxmoxSdnApplyProvider = () =>
|
|
|
109
109
|
*
|
|
110
110
|
* 🔴 WHAT IT PREVENTS. `reconcile` runs on CREATE, the first time this resource appears
|
|
111
111
|
* in a stack, before `diff` has ever been consulted. `PUT /cluster/sdn` regenerates
|
|
112
|
-
* `/etc/network/interfaces.d/sdn` on EVERY node at once — and on
|
|
113
|
-
* the OSPF fabric holding Ceph's `cluster_network` on
|
|
112
|
+
* `/etc/network/interfaces.d/sdn` on EVERY node at once — and on C1 that file carries
|
|
113
|
+
* the OSPF fabric holding Ceph's `cluster_network` on tb0/tb1. Without this branch,
|
|
114
114
|
* adding one line to a stack file reloads networking on all three nodes simultaneously,
|
|
115
115
|
* which is precisely what the serialised NetworkApply chain exists to prevent.
|
|
116
116
|
*
|
|
@@ -22,7 +22,7 @@ export type DhcpRange = { start: string; end: string };
|
|
|
22
22
|
*
|
|
23
23
|
* ⛔ MEASURED from `/usr/share/perl5/PVE/API2/Network/SDN/Subnets.pm` on pve-manager 9.2.11, in
|
|
24
24
|
* the create handler: `my $id = $cidr =~ s/\//-/r; $id = "$zoneid-$id";`. So `subnet=10.0.0.0/24`
|
|
25
|
-
* goes out and `
|
|
25
|
+
* goes out and `lab-10.0.0.0-24` is what exists afterwards. A CIDR cannot be a path segment
|
|
26
26
|
* anyway — it carries a slash — so there is no spelling of this endpoint that takes the CIDR.
|
|
27
27
|
*
|
|
28
28
|
* ⚠️ `.replace`, NOT `.replaceAll`, AND THE PARITY IS DELIBERATE. Perl's `s/\//-/r` has no `/g`, so
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `vnet`; a literal buys no ordering, and here it also buys a wrong path.
|
|
12
12
|
*
|
|
13
13
|
* ⛔ THE ID PVE FILES A SUBNET UNDER IS NOT THE ONE YOU WRITE, AND IT IS THIS FAMILY'S SHARPEST
|
|
14
|
-
* EDGE. `POST` takes `subnet=10.0.0.0/24` while GET, PUT and DELETE address `
|
|
14
|
+
* EDGE. `POST` takes `subnet=10.0.0.0/24` while GET, PUT and DELETE address `lab-10.0.0.0-24`.
|
|
15
15
|
* One PVE parameter name, `subnet`, carrying two different values — so they are named APART here,
|
|
16
16
|
* `cidr` for the declared one and `subnetId` for the derived one, for the reason values.ts gives
|
|
17
17
|
* about `flag`: a name that means two things is a bug waiting for whoever reads one meaning. The
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
* seven privileges include SDN.Audit — at `/` with propagate=1. So the default 3600s auditor
|
|
51
51
|
* lease reads this family, and a `readRole: 'provision'` here would be privilege nobody needed.
|
|
52
52
|
* Writes are a different answer: POST, PUT and DELETE all require SDN.Allocate on
|
|
53
|
-
* `/sdn/zones/<zone>/<vnet>`, which
|
|
53
|
+
* `/sdn/zones/<zone>/<vnet>`, which the provision role gained on 2026-09-13 (`PROVISION_PRIVILEGES`).
|
|
54
54
|
*
|
|
55
|
-
* ⚠️ NO LIVE SUBNET EXISTS ON
|
|
55
|
+
* ⚠️ NO LIVE SUBNET EXISTS ON C1 TO ROUND-TRIP AGAINST — there are no SDN zones and no vnets, so
|
|
56
56
|
* there can be no subnets. Every claim above is read off PVE 9.2.11's own schema and Perl sources
|
|
57
|
-
* on
|
|
57
|
+
* on node-b, and the noop argument below rests on those rather than on a plan this file has watched.
|
|
58
58
|
*
|
|
59
59
|
* ⚠️ TWO REFUSALS TO EXPECT, both from `SubnetPlugin::on_update_hook`: PVE will not put a subnet on
|
|
60
60
|
* a vnet with `vlanaware` set ("you can't add a subnet on a vlanaware vnet"), and it rejects a
|
|
@@ -98,7 +98,7 @@ export interface SdnSubnetProps extends WithTarget {
|
|
|
98
98
|
gateway?: string;
|
|
99
99
|
/**
|
|
100
100
|
* Masquerade this subnet. ⚠️ INERT UNTIL THE CLUSTER FIREWALL IS ON — PVE's own description is
|
|
101
|
-
* "enable masquerade for this subnet if pve-firewall", and
|
|
101
|
+
* "enable masquerade for this subnet if pve-firewall", and C1's cluster firewall was empty and
|
|
102
102
|
* disabled when this was written. It still round-trips, so declaring it does not diff.
|
|
103
103
|
*/
|
|
104
104
|
snat?: boolean;
|
package/src/proxmox/sdn-vnet.ts
CHANGED
|
@@ -35,12 +35,11 @@
|
|
|
35
35
|
* than an error — see the ⛔ in sdn-zone.ts and docs/privileges.md.
|
|
36
36
|
*
|
|
37
37
|
* ⚠️ PRIVILEGES, AND THE ESTATE'S CREDENTIAL HELD NONE OF THEM UNTIL 2026-09-13. reconcile and
|
|
38
|
-
* delete mint `provision` and need SDN.Allocate on `/sdn/zones`, or on the one zone if scoped.
|
|
39
|
-
* SDN.Use, which is what a GUEST needs in order to attach to a vnet and NOT what
|
|
40
|
-
* needs, so a container
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* preserving every existing privilege.
|
|
38
|
+
* delete mint `provision` and need SDN.Allocate on `/sdn/zones`, or on the one zone if scoped. The provision role granted
|
|
39
|
+
* only SDN.Use then, which is what a GUEST needs in order to attach to a vnet and NOT what
|
|
40
|
+
* creating one needs, so a container on a vnet worked while the vnet itself 403'd. It was widened
|
|
41
|
+
* the way Pool.Allocate was, and `PROVISION_PRIVILEGES` (provision-baseline.ts) now carries
|
|
42
|
+
* SDN.Allocate and SDN.Audit.
|
|
44
43
|
*/
|
|
45
44
|
import { Resource } from 'alchemy';
|
|
46
45
|
import * as Provider from 'alchemy/Provider';
|
package/src/proxmox/storage.ts
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* messages point away from the cause.
|
|
22
22
|
* ★ SO WIDEN OR RE-POINT BEFORE THE FIRST PLAN, outside this file: give the credential mount's
|
|
23
23
|
* `read` role `Datastore.Allocate` on `/storage`, or read this family with `provision`. The
|
|
24
|
-
* provision role in the estate this was written for
|
|
24
|
+
* provision role in the estate this was written for held only `Datastore.AllocateSpace` and
|
|
25
25
|
* `Datastore.Audit`, and NEITHER IS IT — AllocateSpace writes volumes INTO a storage, Allocate
|
|
26
|
-
* DEFINES one — so it
|
|
26
|
+
* DEFINES one — so it 403'd here until widened the way `Pool.Allocate` was for `Proxmox.Pool`.
|
|
27
|
+
* The provisioning baseline (`provision-baseline.ts`) carries `Datastore.Allocate`.
|
|
27
28
|
*
|
|
28
29
|
* ⛔ THERE IS NO `password` PROP AND THERE MUST NEVER BE ONE. PBS and CIFS storages take one at
|
|
29
30
|
* create time, and Alchemy persists attributes UNENCRYPTED — a secret in props is one careless
|
|
@@ -68,7 +69,7 @@ export interface StorageProps extends WithTarget {
|
|
|
68
69
|
/** `images,rootdir,vztmpl,iso,backup,snippets`. PVE reports `none` for an empty set. */
|
|
69
70
|
content?: string;
|
|
70
71
|
/**
|
|
71
|
-
* Node restriction, e.g. `
|
|
72
|
+
* Node restriction, e.g. `node-a,node-b`. ⚠️ A STORAGE WITH A `nodes` LIST DOES NOT EXIST ANYWHERE ELSE:
|
|
72
73
|
* an LXC declared on a node outside it fails at create with "storage not available on node".
|
|
73
74
|
*/
|
|
74
75
|
nodes?: string;
|
package/src/proxmox/user.ts
CHANGED
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
* can then say what a delete would take with it — and kept OUT of `matches`, exactly like
|
|
30
30
|
* `pool.members`. Reported, never declared.
|
|
31
31
|
*
|
|
32
|
-
* ⚠️ RECONCILE NEEDS PRIVILEGES
|
|
33
|
-
* 403 in the middle of a deploy (pool.ts records how that went the last time)
|
|
32
|
+
* ⚠️ RECONCILE NEEDS PRIVILEGES A GUEST-ONLY ROLE DOES NOT HOLD, so they are stated rather than met
|
|
33
|
+
* as a 403 in the middle of a deploy (pool.ts records how that went the last time).
|
|
34
|
+
* `PROVISION_PRIVILEGES` (provision-baseline.ts) carries every one of them:
|
|
34
35
|
* · `Realm.AllocateUser` on `/access/realm/<realm>` — create and delete.
|
|
35
36
|
* · `User.Modify` on `/access/groups`, and on `/access/groups/<group>` for EVERY group named in
|
|
36
37
|
* `groups` — create, update and delete. PVE checks the groups you are granting, not just the
|
|
@@ -99,11 +99,11 @@ describe('num and text', () => {
|
|
|
99
99
|
|
|
100
100
|
describe('csv — a set PVE does not give back in the order it was handed', () => {
|
|
101
101
|
it('is order insensitive, so two orderings compare equal', () => {
|
|
102
|
-
assert.equal(csv(['
|
|
102
|
+
assert.equal(csv(['node-c', 'node-b']), csv('node-b,node-c'));
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it('trims and drops empties, so a trailing comma is not a phantom member', () => {
|
|
106
|
-
assert.equal(csv('
|
|
106
|
+
assert.equal(csv('node-b, node-c,'), 'node-b,node-c');
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
109
|
|
package/src/proxmox/values.ts
CHANGED
|
@@ -168,7 +168,7 @@ export const int = (value: unknown, fallback: number) => {
|
|
|
168
168
|
* A PVE update form with its clear-list attached, and the two guaranteed not to overlap.
|
|
169
169
|
*
|
|
170
170
|
* ⛔ PVE DIES IF A KEY IS SET AND DELETED IN ONE CALL, AND SIX FAMILIES HERE BUILT THAT FORM BY
|
|
171
|
-
* HAND. Measured in the shipped Perl on
|
|
171
|
+
* HAND. Measured in the shipped Perl on node-b — `PVE::SectionConfig::delete_from_config`, line 1860:
|
|
172
172
|
*
|
|
173
173
|
* die "cannot set and delete property '$k' at the same time!\n"
|
|
174
174
|
* if defined($new_options->{$k});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* write-only.ts — resolving `{ fromEnv }` and the seal a plan compares instead of a value.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE SEAL IS THE WHOLE CHANGE-DETECTION STORY FOR A SECRET THE SERVER WILL NOT RETURN, so each
|
|
5
|
+
* property it relies on is pinned: the same values verify, a different value does not, key order
|
|
6
|
+
* does not matter, and nothing malformed ever reads as a match.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { resolveAll, seal, sealMatches } from './write-only.ts';
|
|
10
|
+
|
|
11
|
+
describe('resolveAll', () => {
|
|
12
|
+
test('resolves by variable name and reports every missing or empty one, by name', () => {
|
|
13
|
+
const env = { HOOK: 's3cret', EMPTY: '' };
|
|
14
|
+
const got = resolveAll(
|
|
15
|
+
{ a: { fromEnv: 'HOOK' }, b: { fromEnv: 'EMPTY' }, c: { fromEnv: 'UNSET' } },
|
|
16
|
+
env,
|
|
17
|
+
);
|
|
18
|
+
expect(got.values).toEqual({ a: 's3cret' });
|
|
19
|
+
expect(got.missing).toEqual(['EMPTY', 'UNSET']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('does not trim: the value sent is the value in the environment', () => {
|
|
23
|
+
expect(resolveAll({ a: { fromEnv: 'X' } }, { X: ' v\n' }).values).toEqual({ a: ' v\n' });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('seal', () => {
|
|
28
|
+
const values = { 'secret:token': 'correct horse', password: 'battery staple' };
|
|
29
|
+
|
|
30
|
+
test('verifies the values it was made from, and no others', () => {
|
|
31
|
+
const sealed = seal(values);
|
|
32
|
+
expect(sealed).toStartWith('scrypt:');
|
|
33
|
+
expect(sealMatches(sealed, values)).toBe(true);
|
|
34
|
+
expect(sealMatches(sealed, { ...values, password: 'battery staplf' })).toBe(false);
|
|
35
|
+
expect(sealMatches(sealed, { 'secret:token': 'correct horse' })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('never contains a value, raw or base64', () => {
|
|
39
|
+
const sealed = seal(values);
|
|
40
|
+
for (const value of Object.values(values)) {
|
|
41
|
+
expect(sealed).not.toContain(value);
|
|
42
|
+
expect(sealed).not.toContain(Buffer.from(value).toString('base64'));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('key order is not a change', () => {
|
|
47
|
+
const sealed = seal({ a: '1', b: '2' });
|
|
48
|
+
expect(sealMatches(sealed, { b: '2', a: '1' })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a random salt by default: sealing twice gives two seals that both verify', () => {
|
|
52
|
+
const one = seal(values);
|
|
53
|
+
const two = seal(values);
|
|
54
|
+
expect(one).not.toBe(two);
|
|
55
|
+
expect(sealMatches(two, values)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a fixed salt is deterministic — what a header digest needs to not churn state', () => {
|
|
59
|
+
expect(seal(values, 'fixed')).toBe(seal(values, 'fixed'));
|
|
60
|
+
expect(seal(values, 'fixed')).not.toBe(seal(values, 'other'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('nothing to seal is the empty string, and only nothing matches it', () => {
|
|
64
|
+
expect(seal({})).toBe('');
|
|
65
|
+
expect(sealMatches('', {})).toBe(true);
|
|
66
|
+
expect(sealMatches('', values)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a malformed seal is never a match', () => {
|
|
70
|
+
const [, salt, digest] = seal(values).split(':');
|
|
71
|
+
for (const bad of [`md5:${salt}:${digest}`, `scrypt:${salt}`, `scrypt:${salt}:${digest}:x`]) {
|
|
72
|
+
expect(sealMatches(bad, values)).toBe(false);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Values a declaration may SEND and must never STORE — a webhook secret, a header carrying a
|
|
3
|
+
* credential, an smtp password — and the one form in which a plan may still remember them.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ A SECRET PROP IS A SECRET IN THE STATE STORE, WHATEVER TYPE IT HAS. Alchemy persists props as
|
|
6
|
+
* well as attributes — `delete` is handed `olds`, which can only have come from the store — and
|
|
7
|
+
* it does not encrypt either: `StateEncoding.ts` TAGS a `Redacted` value and writes the inner
|
|
8
|
+
* value beside the tag. So `Redacted` hides a secret from a log line and from nothing else.
|
|
9
|
+
* notification-target.ts (PVE) answered that by making no secret declarable at all, and paid
|
|
10
|
+
* for it: an authenticated smtp target or a signed webhook could not be created from code.
|
|
11
|
+
* ★ SO THE PROP HOLDS A NAME, NOT A VALUE. `{ fromEnv: 'PBS_HOOK_TOKEN' }` names the variable the
|
|
12
|
+
* DEPLOYING process reads at call time — the shape forgejo/org-actions-secrets.ts already uses.
|
|
13
|
+
* The name is what lands in state; the value exists only in that process's memory and on the
|
|
14
|
+
* wire to the server.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ A PLAN STILL NEEDS TO KNOW WHETHER A VALUE CHANGED, AND IT CANNOT ASK THE SERVER. PBS returns a
|
|
17
|
+
* webhook secret's NAME and never its value, and never returns an smtp password at all. So what
|
|
18
|
+
* was last written is remembered as a SEAL: a salted scrypt digest, which lets the next plan
|
|
19
|
+
* check "is the value in my environment the one I wrote" without the store holding anything a
|
|
20
|
+
* reader could send to PBS.
|
|
21
|
+
* ⚠️ A DIGEST OF A GUESSABLE SECRET IS STILL GUESSABLE. The salt defeats precomputed tables and
|
|
22
|
+
* scrypt makes each guess cost memory and time; neither makes a dictionary word safe. The
|
|
23
|
+
* remedy is a secret worth the name — a random token — not a faster hash.
|
|
24
|
+
*/
|
|
25
|
+
import { Buffer } from 'node:buffer';
|
|
26
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
/** A write-only value, by the NAME of the environment variable holding it. Never the value. */
|
|
29
|
+
export interface FromEnv {
|
|
30
|
+
readonly fromEnv: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a value is looked up. Defaults to `process.env`, read at call time, never at load. */
|
|
34
|
+
export type Environment = Readonly<Record<string, string | undefined>>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Each `name → FromEnv` resolved, or the variable names that could not be.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ AN EMPTY VARIABLE IS A MISSING ONE. A secret renderer that failed writes `NAME=` rather than
|
|
40
|
+
* omitting the line, and sending `''` would store an empty secret on the server — a webhook that
|
|
41
|
+
* authenticates with nothing, reported as a successful deploy.
|
|
42
|
+
* ⚠️ THE VALUE IS NOT TRIMMED. A token with a trailing newline is a broken header either way, and
|
|
43
|
+
* trimming here would make the seal disagree with a value written by any other tool.
|
|
44
|
+
*/
|
|
45
|
+
export const resolveAll = (
|
|
46
|
+
refs: Readonly<Record<string, FromEnv>>,
|
|
47
|
+
env: Environment = process.env,
|
|
48
|
+
): { readonly values: Readonly<Record<string, string>>; readonly missing: readonly string[] } => {
|
|
49
|
+
const values: Record<string, string> = {};
|
|
50
|
+
const missing: string[] = [];
|
|
51
|
+
for (const [key, ref] of Object.entries(refs)) {
|
|
52
|
+
const value = env[ref.fromEnv];
|
|
53
|
+
if (value === undefined || value === '') missing.push(ref.fromEnv);
|
|
54
|
+
else values[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return { missing: [...new Set(missing)].sort(), values };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ★ PARAMETERS ARE NODE'S OWN SCRYPT DEFAULTS (N 2^14, r 8, p 1) — 16 MiB and tens of
|
|
61
|
+
* milliseconds per digest. Plan runs it a handful of times per resource, which is noise, and a
|
|
62
|
+
* guesser runs it once per guess, which is the point.
|
|
63
|
+
*/
|
|
64
|
+
const COST = { N: 16_384, maxmem: 64 * 1024 * 1024, p: 1, r: 8 } as const;
|
|
65
|
+
const PREFIX = 'scrypt';
|
|
66
|
+
|
|
67
|
+
/** Key order must not change a digest, so entries are sorted and serialised as JSON pairs. */
|
|
68
|
+
const canonical = (values: Readonly<Record<string, string>>): string =>
|
|
69
|
+
JSON.stringify(
|
|
70
|
+
Object.keys(values)
|
|
71
|
+
.sort()
|
|
72
|
+
.map((key) => [key, values[key]]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const digest = (salt: Buffer, values: Readonly<Record<string, string>>): Buffer =>
|
|
76
|
+
scryptSync(canonical(values), salt, 32, COST);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `scrypt:<salt>:<digest>`, or `''` when there is nothing to seal.
|
|
80
|
+
*
|
|
81
|
+
* ★ A RANDOM SALT BY DEFAULT, FOR WHAT THIS PROVIDER WROTE. Pass a fixed one only for a value the
|
|
82
|
+
* server returns anyway (a header), where a fresh salt per read would churn the state row on
|
|
83
|
+
* every plan without protecting anything the server does not already show its auditors.
|
|
84
|
+
*/
|
|
85
|
+
export const seal = (values: Readonly<Record<string, string>>, salt?: string): string => {
|
|
86
|
+
if (Object.keys(values).length === 0) return '';
|
|
87
|
+
const bytes = salt === undefined ? randomBytes(16) : Buffer.from(salt, 'utf8');
|
|
88
|
+
return `${PREFIX}:${bytes.toString('base64url')}:${digest(bytes, values).toString('base64url')}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `values` are the ones `sealed` was made from.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ A MALFORMED OR EMPTY SEAL ANSWERS FALSE, never throws: callers decide what "no evidence"
|
|
95
|
+
* means, and every caller here treats it as "cannot tell" rather than as drift.
|
|
96
|
+
*/
|
|
97
|
+
export const sealMatches = (sealed: string, values: Readonly<Record<string, string>>): boolean => {
|
|
98
|
+
if (sealed === '') return Object.keys(values).length === 0;
|
|
99
|
+
const [prefix, salt, stored, extra] = sealed.split(':');
|
|
100
|
+
if (prefix !== PREFIX || salt === undefined || stored === undefined || extra !== undefined) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const expected = Buffer.from(stored, 'base64url');
|
|
104
|
+
const actual = digest(Buffer.from(salt, 'base64url'), values);
|
|
105
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
106
|
+
};
|
|
@@ -51,7 +51,7 @@ const SETTLE_ATTEMPTS = 30;
|
|
|
51
51
|
/**
|
|
52
52
|
* Create the pool if it is absent, and otherwise leave it completely alone.
|
|
53
53
|
*
|
|
54
|
-
* ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on
|
|
54
|
+
* ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on node-b: the
|
|
55
55
|
* create handler ends in `$rpcenv->fork_worker('zfscreate', ...)` and its HTTP answer is a UPID
|
|
56
56
|
* returned the instant the worker is forked — before `zpool create` has run, let alone finished.
|
|
57
57
|
* A read-back taken immediately, as `pveOperations.reconcile` takes it, finds nothing and would
|
package/src/proxmox/zfs-pool.ts
CHANGED
|
@@ -22,22 +22,22 @@
|
|
|
22
22
|
* that one line turns a text edit into an unrecoverable one.
|
|
23
23
|
*
|
|
24
24
|
* ⛔ THERE IS NO PUT ON THIS FAMILY AT ALL. MEASURED from the cluster's own published schema
|
|
25
|
-
* (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on
|
|
25
|
+
* (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on node-b 2026-09-13): `/nodes/{node}/disks/zfs`
|
|
26
26
|
* has GET and POST, `/nodes/{node}/disks/zfs/{name}` has GET and DELETE, and that is the whole
|
|
27
27
|
* surface. So `updateForm` is omitted — which, per `resource.ts`, makes any `matches` = false a
|
|
28
28
|
* REPLACE. A replace is a delete followed by a create, and on this family the delete is the
|
|
29
29
|
* thing above. Read the ⛔ on `matches` before adding anything to it.
|
|
30
30
|
*
|
|
31
31
|
* ⛔ NOT ONE CREATE PARAMETER COMES BACK ON READ, SO `matches` COMPARES NOTHING. MEASURED against
|
|
32
|
-
* the live cluster: `GET /nodes/
|
|
32
|
+
* the live cluster: `GET /nodes/node-b/disks/zfs/rpool` answers exactly
|
|
33
33
|
* `{action, children, errors, leaf, name, scan, state, status}` — no `ashift`, no `compression`,
|
|
34
34
|
* no `raidlevel`, no `devices`, no `add_storage`. Every create parameter is write-only. There is
|
|
35
35
|
* therefore no field a declaration and a live pool can both be asked about, and a `matches` that
|
|
36
36
|
* invented one would plan a replace — i.e. a `zpool destroy` — over a difference it could never
|
|
37
37
|
* verify in the first place.
|
|
38
38
|
*
|
|
39
|
-
* ⚠️ AND WHAT THE INDEX RETURNS IS LIVE TELEMETRY, NOT CONFIGURATION. `GET /nodes/
|
|
40
|
-
* gives `alloc`, `free`, `frag`, `dedup`, `size`, `health`. MEASURED: two reads of
|
|
39
|
+
* ⚠️ AND WHAT THE INDEX RETURNS IS LIVE TELEMETRY, NOT CONFIGURATION. `GET /nodes/node-b/disks/zfs`
|
|
40
|
+
* gives `alloc`, `free`, `frag`, `dedup`, `size`, `health`. MEASURED: two reads of node-b seconds
|
|
41
41
|
* apart returned `alloc` 14474944512 then 14472740864 — it moves on its own, with no declaration
|
|
42
42
|
* anywhere near it. `scan` in the detail read moves the same way (it carries the last scrub), and
|
|
43
43
|
* `status`/`action` appear and vanish as ZFS feature flags and faults come and go. None of them
|
|
@@ -155,7 +155,7 @@ export interface ZfsPoolAttributes {
|
|
|
155
155
|
*
|
|
156
156
|
* ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
|
|
157
157
|
* a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
|
|
158
|
-
* for a pool built on partitions — MEASURED, that is exactly what
|
|
158
|
+
* for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports. Never
|
|
159
159
|
* compare it with `props.devices`.
|
|
160
160
|
*/
|
|
161
161
|
devices: string;
|
|
@@ -219,7 +219,7 @@ const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes> = {
|
|
|
219
219
|
* can be compared on: every create parameter is write-only (see the ⛔ in the header) and
|
|
220
220
|
* everything the read does return is telemetry that moves by itself. So declaring what is live
|
|
221
221
|
* plans as `noop` — the only honest answer available, and the one the live cluster needs:
|
|
222
|
-
* `rpool` exists on
|
|
222
|
+
* `rpool` exists on node-b, node-c and node-d with nothing about its construction readable.
|
|
223
223
|
* ⛔ ANYTHING ADDED HERE IS A REPLACE, NOT AN UPDATE. `updateForm` is omitted because PVE has
|
|
224
224
|
* no PUT, so `resource.ts` turns a false into `{action:'replace'}` — delete then create — on
|
|
225
225
|
* a family whose delete is `zpool destroy`. A comparison added here would be one plan away
|
package/src/talos/credentials.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* ⛔ TALOSCONFIG AND secrets.yaml HOLD CLIENT CERTIFICATES AND CLUSTER CA PRIVATE KEYS. Alchemy
|
|
5
5
|
* persists resource attributes WITHOUT encryption — StateEncoding.ts writes `Redacted` as
|
|
6
6
|
* `{"@redacted": <plaintext>}` — and this estate's state store is the `alchemy` Postgres that
|
|
7
|
-
*
|
|
7
|
+
* a nightly job dumps. Neither file may be a prop, an attribute, or a log line.
|
|
8
8
|
*
|
|
9
|
-
* ★ MODELED ON
|
|
9
|
+
* ★ MODELED ON <estate>/proxmox/src/credentials.ts: mint at call time through `ChildProcessSpawner`,
|
|
10
10
|
* use the material to build a client, drop it when the scope ends. The difference is Talos wants a
|
|
11
11
|
* FILE (`--talosconfig` / `TALOSCONFIG`), so mint writes a temp path and registers cleanup.
|
|
12
12
|
*
|
|
@@ -21,7 +21,7 @@ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawne
|
|
|
21
21
|
|
|
22
22
|
/** Where credentials come from. HomeFlare-specific mount names live in the stack, not here. */
|
|
23
23
|
export type TalosTarget = {
|
|
24
|
-
/** OpenBao KV mount holding Talos material, e.g. `talos-
|
|
24
|
+
/** OpenBao KV mount holding Talos material, e.g. `talos-c1`. */
|
|
25
25
|
readonly mount: string;
|
|
26
26
|
/** Logical cluster name — safe to persist and log. */
|
|
27
27
|
readonly cluster: string;
|
package/src/talos/values.test.ts
CHANGED
|
@@ -21,21 +21,21 @@ const FIXTURE_KUBECONFIG = `
|
|
|
21
21
|
apiVersion: v1
|
|
22
22
|
kind: Config
|
|
23
23
|
clusters:
|
|
24
|
-
- name: hf-
|
|
24
|
+
- name: hf-c1
|
|
25
25
|
cluster:
|
|
26
|
-
server: https://
|
|
26
|
+
server: https://192.0.2.50:6443
|
|
27
27
|
certificate-authority-data: ${b64('not a ca')}
|
|
28
28
|
contexts:
|
|
29
|
-
- name: admin@hf-
|
|
29
|
+
- name: admin@hf-c1
|
|
30
30
|
context:
|
|
31
|
-
cluster: hf-
|
|
32
|
-
user: admin@hf-
|
|
31
|
+
cluster: hf-c1
|
|
32
|
+
user: admin@hf-c1
|
|
33
33
|
users:
|
|
34
|
-
- name: admin@hf-
|
|
34
|
+
- name: admin@hf-c1
|
|
35
35
|
user:
|
|
36
36
|
client-certificate-data: ${b64('not a certificate')}
|
|
37
37
|
client-key-data: ${b64('not a key')}
|
|
38
|
-
current-context: admin@hf-
|
|
38
|
+
current-context: admin@hf-c1
|
|
39
39
|
`;
|
|
40
40
|
|
|
41
41
|
describe('sha256', () => {
|
|
@@ -53,9 +53,9 @@ describe('configDigest', () => {
|
|
|
53
53
|
|
|
54
54
|
describe('kubeconfigMetadata', () => {
|
|
55
55
|
it('extracts public fields without returning PEM bytes', () => {
|
|
56
|
-
const meta = kubeconfigMetadata(FIXTURE_KUBECONFIG, 'admin@hf-
|
|
56
|
+
const meta = kubeconfigMetadata(FIXTURE_KUBECONFIG, 'admin@hf-c1');
|
|
57
57
|
assert.ok(meta);
|
|
58
|
-
assert.equal(meta.endpoint, 'https://
|
|
58
|
+
assert.equal(meta.endpoint, 'https://192.0.2.50:6443');
|
|
59
59
|
// ⚠️ HASHED FROM THE SAME ENCODER THE FIXTURE USES, so the assertion cannot drift from it —
|
|
60
60
|
// and so no base64 blob has to appear in this file. See the ⛔ at the top.
|
|
61
61
|
assert.equal(meta.caFingerprint, sha256(b64('not a ca')));
|
package/src/verify/cli.test.ts
CHANGED
|
@@ -130,6 +130,11 @@ export interface FakeEngine {
|
|
|
130
130
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
131
131
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
132
132
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
133
|
+
/**
|
|
134
|
+
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
135
|
+
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
136
|
+
*/
|
|
137
|
+
readonly stored: () => string;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/**
|
|
@@ -138,7 +143,8 @@ export interface FakeEngine {
|
|
|
138
143
|
* ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY — openbao/fake-stack.ts has the reasoning.
|
|
139
144
|
*/
|
|
140
145
|
export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): FakeEngine => {
|
|
141
|
-
const
|
|
146
|
+
const rows: NonNullable<Parameters<typeof Alchemy.inMemoryState>[0]> = {};
|
|
147
|
+
const state = Alchemy.inMemoryState(rows);
|
|
142
148
|
const stack = Alchemy.Stack as unknown as (
|
|
143
149
|
name: string,
|
|
144
150
|
options: { providers: typeof layer; state: typeof state },
|
|
@@ -161,6 +167,7 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
|
|
|
161
167
|
);
|
|
162
168
|
|
|
163
169
|
return {
|
|
170
|
+
stored: () => JSON.stringify(rows),
|
|
164
171
|
deploy: (body) =>
|
|
165
172
|
run(body, (compiled) =>
|
|
166
173
|
Effect.gen(function* () {
|