@homeflare/alchemy 0.39.0 → 0.41.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 +10 -8
- package/dist/caddy/index.js.map +2 -2
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1610 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +62 -39
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +7 -3
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ceph allowlist, as pure argv checks — no ssh, no fake dial. Every acceptance is a shape
|
|
3
|
+
* ceph-argv.ts's own builders actually produce; every refusal is a shape that would do real damage
|
|
4
|
+
* (a printed key, a cut mon, a de-authenticated cluster) if it ever reached `sudo -n ceph`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import {
|
|
8
|
+
CEPH_BIN,
|
|
9
|
+
authCapsArgv,
|
|
10
|
+
authGetArgv,
|
|
11
|
+
authGetOrCreateArgv,
|
|
12
|
+
boundedEntityProblem,
|
|
13
|
+
cephCommandProblem,
|
|
14
|
+
configValueProblem,
|
|
15
|
+
quorumStatusArgv,
|
|
16
|
+
} from './ceph-argv.ts';
|
|
17
|
+
|
|
18
|
+
const ENTITY = 'client.k8s-rbd';
|
|
19
|
+
const CAPS = {
|
|
20
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
21
|
+
mon: 'profile rbd',
|
|
22
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('the allowed shapes, byte-for-byte', () => {
|
|
26
|
+
test('auth get', () => {
|
|
27
|
+
expect(cephCommandProblem(authGetArgv(ENTITY))).toBeUndefined();
|
|
28
|
+
expect(authGetArgv(ENTITY)).toEqual([CEPH_BIN, 'auth', 'get', ENTITY, '-f', 'json']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('auth get-or-create', () => {
|
|
32
|
+
expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, CAPS))).toBeUndefined();
|
|
33
|
+
expect(authGetOrCreateArgv(ENTITY, CAPS)).toEqual([
|
|
34
|
+
CEPH_BIN,
|
|
35
|
+
'auth',
|
|
36
|
+
'get-or-create',
|
|
37
|
+
ENTITY,
|
|
38
|
+
'mon',
|
|
39
|
+
CAPS.mon,
|
|
40
|
+
'osd',
|
|
41
|
+
CAPS.osd,
|
|
42
|
+
'mgr',
|
|
43
|
+
CAPS.mgr,
|
|
44
|
+
'-f',
|
|
45
|
+
'json',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('auth caps', () => {
|
|
50
|
+
expect(cephCommandProblem(authCapsArgv(ENTITY, CAPS))).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('quorum_status', () => {
|
|
54
|
+
expect(cephCommandProblem(quorumStatusArgv())).toBeUndefined();
|
|
55
|
+
expect(quorumStatusArgv()).toEqual([CEPH_BIN, 'quorum_status', '-f', 'json']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('the entity prefix', () => {
|
|
60
|
+
test('accepts a bounded client.k8s- name', () => {
|
|
61
|
+
expect(boundedEntityProblem('client.k8s-rbd')).toBeUndefined();
|
|
62
|
+
expect(boundedEntityProblem('client.k8s-cephfs')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test.each([
|
|
66
|
+
['the cluster admin keyring', 'client.admin'],
|
|
67
|
+
['a mon keyring', 'mon.a'],
|
|
68
|
+
['an osd keyring', 'osd.0'],
|
|
69
|
+
['an mgr keyring', 'mgr.a'],
|
|
70
|
+
['the PVE storage client', 'client.pve-storage'],
|
|
71
|
+
['a bare client prefix', 'client.k8s-'],
|
|
72
|
+
['no client prefix at all', 'k8s-rbd'],
|
|
73
|
+
])('refuses %s', (_name, entity) => {
|
|
74
|
+
expect(boundedEntityProblem(entity)).toBeString();
|
|
75
|
+
expect(cephCommandProblem(authGetArgv(entity))).toBeString();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('shell metacharacters never reach the allowlist as data', () => {
|
|
80
|
+
test.each([
|
|
81
|
+
['in the entity', authGetArgv('client.k8s-rbd; rm -rf /')],
|
|
82
|
+
['in a cap, backtick', authGetOrCreateArgv(ENTITY, { ...CAPS, osd: 'profile rbd `id`' })],
|
|
83
|
+
['in a cap, command substitution', authCapsArgv(ENTITY, { ...CAPS, mon: '$(whoami)' })],
|
|
84
|
+
['in a cap, a pipe', authCapsArgv(ENTITY, { ...CAPS, mgr: 'profile rbd | cat /etc/passwd' })],
|
|
85
|
+
[
|
|
86
|
+
'in a cap, a semicolon',
|
|
87
|
+
authCapsArgv(ENTITY, { ...CAPS, mon: 'profile rbd; ceph auth del client.admin' }),
|
|
88
|
+
],
|
|
89
|
+
])('refuses %s', (_name, argv) => {
|
|
90
|
+
expect(cephCommandProblem(argv)).toBeString();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('K-A4 finding 3: caps are the design’s literal shape, not a general grammar', () => {
|
|
95
|
+
test.each([
|
|
96
|
+
['plain rwx, no profile at all', 'osd', 'allow rwx'],
|
|
97
|
+
['a non-rbd profile', 'osd', 'profile osd'],
|
|
98
|
+
['bootstrap-osd', 'osd', 'profile bootstrap-osd'],
|
|
99
|
+
['a comma-chained extra grant', 'osd', 'profile rbd pool=k8s-rbd, allow rwx'],
|
|
100
|
+
['osd rbd with no pool — every pool, VM disks included', 'osd', 'profile rbd'],
|
|
101
|
+
['mgr rbd with no pool', 'mgr', 'profile rbd'],
|
|
102
|
+
['mon widened past the plain profile', 'mon', 'profile rbd pool=k8s-rbd'],
|
|
103
|
+
])('refuses %s (%s cap %j)', (_name, daemon, value) => {
|
|
104
|
+
const caps = { ...CAPS, [daemon]: value };
|
|
105
|
+
expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, caps))).toBeString();
|
|
106
|
+
expect(cephCommandProblem(authCapsArgv(ENTITY, caps))).toBeString();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the design’s own three caps are still accepted', () => {
|
|
110
|
+
expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, CAPS))).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('the never-allowed auth verbs', () => {
|
|
115
|
+
test('auth ls is refused, naming the reason', () => {
|
|
116
|
+
const problem = cephCommandProblem([CEPH_BIN, 'auth', 'ls', '-f', 'json']);
|
|
117
|
+
expect(problem).toContain('prints every key');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('auth del is refused, naming lockout safety, for any entity', () => {
|
|
121
|
+
for (const entity of [ENTITY, 'client.admin', 'mon.a']) {
|
|
122
|
+
const problem = cephCommandProblem([CEPH_BIN, 'auth', 'del', entity, '-f', 'json']);
|
|
123
|
+
expect(problem).toContain('never auto-delete');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('shape mismatches', () => {
|
|
129
|
+
test.each([
|
|
130
|
+
['bare ceph, no group', [CEPH_BIN]],
|
|
131
|
+
['an unknown group', [CEPH_BIN, 'status', '-f', 'json']],
|
|
132
|
+
['auth get with an extra operand', [CEPH_BIN, 'auth', 'get', ENTITY, 'extra', '-f', 'json']],
|
|
133
|
+
['auth get with an unexpected flag', [CEPH_BIN, 'auth', 'get', ENTITY, '--yes', '-f', 'json']],
|
|
134
|
+
[
|
|
135
|
+
'auth get-or-create missing a daemon',
|
|
136
|
+
[CEPH_BIN, 'auth', 'get-or-create', ENTITY, 'mon', CAPS.mon, '-f', 'json'],
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
'auth caps with daemons out of order',
|
|
140
|
+
[
|
|
141
|
+
CEPH_BIN,
|
|
142
|
+
'auth',
|
|
143
|
+
'caps',
|
|
144
|
+
ENTITY,
|
|
145
|
+
'osd',
|
|
146
|
+
CAPS.osd,
|
|
147
|
+
'mon',
|
|
148
|
+
CAPS.mon,
|
|
149
|
+
'mgr',
|
|
150
|
+
CAPS.mgr,
|
|
151
|
+
'-f',
|
|
152
|
+
'json',
|
|
153
|
+
],
|
|
154
|
+
],
|
|
155
|
+
['missing the -f json suffix', [CEPH_BIN, 'auth', 'get', ENTITY]],
|
|
156
|
+
['-f without json', [CEPH_BIN, 'auth', 'get', ENTITY, '-f', 'plain']],
|
|
157
|
+
['a relative ceph, not absolute', ['ceph', 'auth', 'get', ENTITY, '-f', 'json']],
|
|
158
|
+
['quorum_status with an operand', [CEPH_BIN, 'quorum_status', ENTITY, '-f', 'json']],
|
|
159
|
+
])('refuses %s', (_name, argv) => {
|
|
160
|
+
expect(cephCommandProblem(argv)).toBeString();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('config: the named list starts empty, so every shape is refused today', () => {
|
|
165
|
+
test.each([
|
|
166
|
+
['get', [CEPH_BIN, 'config', 'get', 'global', 'mon_allow_pool_delete', '-f', 'json']],
|
|
167
|
+
['set', [CEPH_BIN, 'config', 'set', 'global', 'mon_allow_pool_delete', 'true', '-f', 'json']],
|
|
168
|
+
['rm', [CEPH_BIN, 'config', 'rm', 'global', 'mon_allow_pool_delete', '-f', 'json']],
|
|
169
|
+
])('config %s is refused: not on the named list', (_verb, argv) => {
|
|
170
|
+
const problem = cephCommandProblem(argv);
|
|
171
|
+
expect(problem).toContain('named option list');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a lockout-class name is refused even if someone tried to add it', () => {
|
|
175
|
+
const problem = cephCommandProblem([
|
|
176
|
+
CEPH_BIN,
|
|
177
|
+
'config',
|
|
178
|
+
'get',
|
|
179
|
+
'mon',
|
|
180
|
+
'mon_host',
|
|
181
|
+
'-f',
|
|
182
|
+
'json',
|
|
183
|
+
]);
|
|
184
|
+
expect(problem).toContain('lockout-class');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('an unbounded `who` is refused', () => {
|
|
188
|
+
const problem = cephCommandProblem([
|
|
189
|
+
CEPH_BIN,
|
|
190
|
+
'config',
|
|
191
|
+
'get',
|
|
192
|
+
'literally anything',
|
|
193
|
+
'x',
|
|
194
|
+
'-f',
|
|
195
|
+
'json',
|
|
196
|
+
]);
|
|
197
|
+
expect(problem).toBeString();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a config value with a shell metacharacter is refused', () => {
|
|
201
|
+
const argv = [CEPH_BIN, 'config', 'set', 'global', 'x', 'true; rm -rf /', '-f', 'json'];
|
|
202
|
+
expect(cephCommandProblem(argv)).toBeString();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('K-A4 finding 4: configValueProblem never accepts a flag-shaped value', () => {
|
|
207
|
+
test.each([
|
|
208
|
+
['a short flag', '-n'],
|
|
209
|
+
['a file-overwrite flag glued to its argument', '-o/etc/ceph/ceph.conf'],
|
|
210
|
+
['a long flag with =', '--admin-daemon=/var/run/ceph/ceph-mon.a.asok'],
|
|
211
|
+
['another long flag', '--conf=/tmp/x'],
|
|
212
|
+
['the empty string', ''],
|
|
213
|
+
['a bare dash', '-'],
|
|
214
|
+
])('refuses %s (%j)', (_name, value) => {
|
|
215
|
+
expect(configValueProblem(value)).toBeString();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a negative integer is still a plain value, not a flag', () => {
|
|
219
|
+
expect(configValueProblem('-42')).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an ordinary value is accepted', () => {
|
|
223
|
+
expect(configValueProblem('true')).toBeUndefined();
|
|
224
|
+
expect(configValueProblem('3600')).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side argv allowlist for the Ceph mon-command transport (K-A4) — an allowlist of
|
|
3
|
+
* SHAPES, not of subcommands, the same discipline `../linux/sudo-allowlist.ts` uses for the file
|
|
4
|
+
* family. Pure: no I/O, so every shape is tested without ssh (ceph-argv.test.ts).
|
|
5
|
+
*
|
|
6
|
+
* Design: docs/plans/2026-09-26-ceph-mon-transport.md ("Transport options", T-A) and
|
|
7
|
+
* docs/plans/2026-09-26-ceph-config-options.md (the named option list this file bounds `config`
|
|
8
|
+
* to). Both are REASONED against public Ceph docs, not measured on this estate's cluster — no
|
|
9
|
+
* mon exists yet for a live check, which is exactly why every shape here is bounded as tightly as
|
|
10
|
+
* the design allows rather than merely "shaped like a ceph command".
|
|
11
|
+
*
|
|
12
|
+
* ⛔ `auth ls` IS NEVER ALLOWED. It prints every key on the cluster, and the estate's own
|
|
13
|
+
* 2026-09-25 lesson is that an exclusion filter is how a token leaks — allowlist extraction
|
|
14
|
+
* only, never "everything except". A caller that wants an inventory uses the committed
|
|
15
|
+
* read-only script this design defers to, never this transport.
|
|
16
|
+
* ⛔ `auth del` IS NEVER ALLOWED, PERIOD — not behind a flag, not for any entity. The lockout-safety
|
|
17
|
+
* rule is "never auto-delete" (D3): a wrong delete here de-authenticates whatever the entity's
|
|
18
|
+
* caps still let VM disks depend on, and it does not ask twice.
|
|
19
|
+
* ⛔ THE ENTITY OPERAND IS BOUNDED TO THE `client.k8s-` PREFIX. `client.admin`, `mon.*`, `osd.*`,
|
|
20
|
+
* `mgr.*` and the PVE storage client are the keyrings the cluster and every VM disk run on;
|
|
21
|
+
* nothing this allowlist accepts can name one.
|
|
22
|
+
* ⛔ EVERY CHECK RUNS AGAINST THE FINAL ARGV — `/usr/bin/ceph`, the real subcommand, and the
|
|
23
|
+
* trailing `-f json` the transport always appends — never a pre-transform version, so what is
|
|
24
|
+
* validated is exactly what reaches `sudo -n`.
|
|
25
|
+
*/
|
|
26
|
+
import { configProblem } from './ceph-argv-config.ts';
|
|
27
|
+
|
|
28
|
+
export { configValueProblem } from './ceph-argv-config.ts';
|
|
29
|
+
|
|
30
|
+
/** ★ Absolute, so neither sudo nor `ceph` is looked up on the operator's PATH. */
|
|
31
|
+
export const CEPH_BIN = '/usr/bin/ceph';
|
|
32
|
+
|
|
33
|
+
/** Every entity this transport may ever touch starts with this. */
|
|
34
|
+
export const ENTITY_PREFIX = 'client.k8s-';
|
|
35
|
+
|
|
36
|
+
/** ⚠️ Bounded length too — a cap operand this long is not a plausible pool-scoped rbd profile. */
|
|
37
|
+
const ENTITY = /^client\.k8s-[a-z0-9][a-z0-9-]{0,61}$/;
|
|
38
|
+
|
|
39
|
+
/** `undefined` when `entity` is a name this transport may ever create, get or re-cap. */
|
|
40
|
+
export const boundedEntityProblem = (entity: string): string | undefined =>
|
|
41
|
+
ENTITY.test(entity)
|
|
42
|
+
? undefined
|
|
43
|
+
: `entity must match \`${ENTITY_PREFIX}<name>\` (lowercase, digits, hyphens), got ${JSON.stringify(entity)}`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ⛔ THE DESIGN'S OWN CAPS, LITERALLY — NOT A GENERAL "LOOKS LIKE A CAP STRING" GRAMMAR. LAND red
|
|
47
|
+
* team (2026-09-26), CONFIRMED: an earlier, wider pattern here (any of `[A-Za-z0-9 _.=,-]`, no
|
|
48
|
+
* per-daemon shape) also matched `allow rwx`, bare `profile osd` / `profile bootstrap-osd`,
|
|
49
|
+
* comma-chained grants (`profile rbd pool=k8s-rbd, allow rwx`), and — the sharpest one — an osd
|
|
50
|
+
* `profile rbd` with NO pool, which grants rbd access to every pool on the cluster, the VM-disk
|
|
51
|
+
* pool included. The design (2026-09-26-ceph-mon-transport.md, "Props") only ever declares
|
|
52
|
+
* `mon: 'profile rbd'` and `osd`/`mgr: 'profile rbd pool=<name>'`; these are that shape, exactly,
|
|
53
|
+
* pool name bounded the same way an entity name is.
|
|
54
|
+
*/
|
|
55
|
+
const MON_CAP = /^profile rbd$/;
|
|
56
|
+
const POOL_SCOPED_CAP = /^profile rbd pool=[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
57
|
+
|
|
58
|
+
const DAEMONS = ['mon', 'osd', 'mgr'] as const;
|
|
59
|
+
|
|
60
|
+
/** `undefined` when `value` is `daemon`'s one allowed cap shape (the design's own caps map). */
|
|
61
|
+
const capProblem = (
|
|
62
|
+
daemon: (typeof DAEMONS)[number],
|
|
63
|
+
value: string | undefined,
|
|
64
|
+
): string | undefined => {
|
|
65
|
+
if (value === undefined) return `${daemon} cap is missing`;
|
|
66
|
+
const shape = daemon === 'mon' ? MON_CAP : POOL_SCOPED_CAP;
|
|
67
|
+
return shape.test(value)
|
|
68
|
+
? undefined
|
|
69
|
+
: `${daemon} cap ${JSON.stringify(value)} is not the design's plain pool-scoped rbd profile`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** `auth get <entity>`: the read `Ceph.AuthEntity` uses to observe caps, never the key. */
|
|
73
|
+
const authGetProblem = (ops: readonly string[]): string | undefined => {
|
|
74
|
+
const [entity, ...extra] = ops;
|
|
75
|
+
if (entity === undefined || extra.length !== 0) return 'auth get takes exactly `<entity>`';
|
|
76
|
+
return boundedEntityProblem(entity);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `auth get-or-create <entity> mon <cap> osd <cap> mgr <cap>` and `auth caps` (same operand
|
|
81
|
+
* shape). ★ FIXED ORDER, EXACTLY THREE PAIRS — the design's own caps map is always
|
|
82
|
+
* `{mon, osd, mgr}`; a variable-length daemon list is more allowlist than this family needs,
|
|
83
|
+
* and less allowlist is less to get wrong.
|
|
84
|
+
*/
|
|
85
|
+
const capsTripleProblem = (ops: readonly string[]): string | undefined => {
|
|
86
|
+
const [entity, ...pairs] = ops;
|
|
87
|
+
if (entity === undefined)
|
|
88
|
+
return `takes exactly \`<entity> ${DAEMONS.map((d) => `${d} <cap>`).join(' ')}\``;
|
|
89
|
+
const entityProblem = boundedEntityProblem(entity);
|
|
90
|
+
if (entityProblem !== undefined) return entityProblem;
|
|
91
|
+
if (pairs.length !== DAEMONS.length * 2) {
|
|
92
|
+
return `takes exactly \`<entity> ${DAEMONS.map((d) => `${d} <cap>`).join(' ')}\``;
|
|
93
|
+
}
|
|
94
|
+
for (const [index, daemon] of DAEMONS.entries()) {
|
|
95
|
+
const key = pairs[index * 2];
|
|
96
|
+
const value = pairs[index * 2 + 1];
|
|
97
|
+
if (key !== daemon)
|
|
98
|
+
return `expected \`${daemon}\` in position ${String(index + 1)}, got ${JSON.stringify(key)}`;
|
|
99
|
+
const problem = capProblem(daemon, value);
|
|
100
|
+
if (problem !== undefined) return problem;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const authProblem = (rest: readonly string[]): string | undefined => {
|
|
106
|
+
const [verb, ...ops] = rest;
|
|
107
|
+
if (verb === 'ls') {
|
|
108
|
+
return (
|
|
109
|
+
'auth ls prints every key on the cluster; use the committed read-only inventory ' +
|
|
110
|
+
'script instead, never this transport'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (verb === 'del')
|
|
114
|
+
return 'auth del is refused entirely — lockout safety, never auto-delete (D3)';
|
|
115
|
+
if (verb === 'get') return authGetProblem(ops);
|
|
116
|
+
if (verb === 'get-or-create') return capsTripleProblem(ops);
|
|
117
|
+
if (verb === 'caps') return capsTripleProblem(ops);
|
|
118
|
+
return `ceph auth ${String(verb)} is not on the allowlist`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Every call this allowlist accepts ends in this — the transport appends it, never the caller. */
|
|
122
|
+
const JSON_FORMAT = ['-f', 'json'] as const;
|
|
123
|
+
|
|
124
|
+
const withJsonSuffix = (
|
|
125
|
+
rest: readonly string[],
|
|
126
|
+
check: (ops: readonly string[]) => string | undefined,
|
|
127
|
+
): string | undefined => {
|
|
128
|
+
const suffix = rest.slice(-2);
|
|
129
|
+
if (suffix[0] !== JSON_FORMAT[0] || suffix[1] !== JSON_FORMAT[1]) {
|
|
130
|
+
return 'every ceph call in this allowlist ends `-f json`';
|
|
131
|
+
}
|
|
132
|
+
return check(rest.slice(0, -2));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `undefined` when `argv` is exactly an allowed ceph shape (the FULL argv `sudo -n` would run,
|
|
137
|
+
* `/usr/bin/ceph` included), else the reason it is not.
|
|
138
|
+
*/
|
|
139
|
+
export const cephCommandProblem = (argv: readonly string[]): string | undefined => {
|
|
140
|
+
const [bin, group, ...rest] = argv;
|
|
141
|
+
if (bin !== CEPH_BIN) return `${JSON.stringify(bin)} is not the allowlisted ceph binary`;
|
|
142
|
+
if (group === 'quorum_status') {
|
|
143
|
+
return rest.length === 2 && rest[0] === JSON_FORMAT[0] && rest[1] === JSON_FORMAT[1]
|
|
144
|
+
? undefined
|
|
145
|
+
: 'quorum_status takes exactly `-f json`';
|
|
146
|
+
}
|
|
147
|
+
if (group === 'auth') return withJsonSuffix(rest, authProblem);
|
|
148
|
+
if (group === 'config') return withJsonSuffix(rest, configProblem);
|
|
149
|
+
return `ceph ${String(group)} is not on the allowlist`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** The exact argv for each allowed shape, so callers never hand-assemble one that could drift. */
|
|
153
|
+
export const authGetArgv = (entity: string): readonly string[] => [
|
|
154
|
+
CEPH_BIN,
|
|
155
|
+
'auth',
|
|
156
|
+
'get',
|
|
157
|
+
entity,
|
|
158
|
+
...JSON_FORMAT,
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
export const authGetOrCreateArgv = (
|
|
162
|
+
entity: string,
|
|
163
|
+
caps: Readonly<Record<(typeof DAEMONS)[number], string>>,
|
|
164
|
+
): readonly string[] => [
|
|
165
|
+
CEPH_BIN,
|
|
166
|
+
'auth',
|
|
167
|
+
'get-or-create',
|
|
168
|
+
entity,
|
|
169
|
+
...DAEMONS.flatMap((daemon) => [daemon, caps[daemon]]),
|
|
170
|
+
...JSON_FORMAT,
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
export const authCapsArgv = (
|
|
174
|
+
entity: string,
|
|
175
|
+
caps: Readonly<Record<(typeof DAEMONS)[number], string>>,
|
|
176
|
+
): readonly string[] => [
|
|
177
|
+
CEPH_BIN,
|
|
178
|
+
'auth',
|
|
179
|
+
'caps',
|
|
180
|
+
entity,
|
|
181
|
+
...DAEMONS.flatMap((daemon) => [daemon, caps[daemon]]),
|
|
182
|
+
...JSON_FORMAT,
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
export const quorumStatusArgv = (): readonly string[] => [
|
|
186
|
+
CEPH_BIN,
|
|
187
|
+
'quorum_status',
|
|
188
|
+
...JSON_FORMAT,
|
|
189
|
+
];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure props/state comparison `diff` runs — no ssh, no fake dial, nothing async. Mon-transport
|
|
3
|
+
* doc: "a plan never elevates"; this is the whole of what a plan may ever decide for this family.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
type CephAuthEntityAttributes,
|
|
8
|
+
attributesOf,
|
|
9
|
+
capsEqual,
|
|
10
|
+
cephCliPath,
|
|
11
|
+
cephDataPath,
|
|
12
|
+
identityMoved,
|
|
13
|
+
planCephAuthEntity,
|
|
14
|
+
} from './ceph-auth-form.ts';
|
|
15
|
+
|
|
16
|
+
const CAPS = {
|
|
17
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
18
|
+
mon: 'profile rbd',
|
|
19
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
20
|
+
};
|
|
21
|
+
const OTHER = { ...CAPS, osd: 'profile rbd pool=other' };
|
|
22
|
+
const OUTPUT: CephAuthEntityAttributes = {
|
|
23
|
+
baoPath: 'talos-c1/ceph/client.k8s-rbd',
|
|
24
|
+
caps: CAPS,
|
|
25
|
+
entity: 'client.k8s-rbd',
|
|
26
|
+
fingerprint: 'deadbeef',
|
|
27
|
+
node: 'mon-a.example.test',
|
|
28
|
+
};
|
|
29
|
+
const PROPS = {
|
|
30
|
+
caps: CAPS,
|
|
31
|
+
entity: 'client.k8s-rbd',
|
|
32
|
+
mount: 'talos-c1',
|
|
33
|
+
nodes: ['mon-a.example.test'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('planCephAuthEntity', () => {
|
|
37
|
+
test('a brand-new row (no output) leaves the decision to reconcile', () => {
|
|
38
|
+
expect(planCephAuthEntity(PROPS, undefined)).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('caps unchanged plans noop', () => {
|
|
42
|
+
expect(planCephAuthEntity(PROPS, OUTPUT)).toEqual({ action: 'noop' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('caps changed plans update', () => {
|
|
46
|
+
expect(planCephAuthEntity({ ...PROPS, caps: OTHER }, OUTPUT)).toEqual({ action: 'update' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('K-A4 finding 1: an entity edited in place plans replace, not update, even with caps changed too', () => {
|
|
50
|
+
expect(
|
|
51
|
+
planCephAuthEntity({ ...PROPS, caps: OTHER, entity: 'client.k8s-other' }, OUTPUT),
|
|
52
|
+
).toEqual({ action: 'replace' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a mount change alone is the same identity move', () => {
|
|
56
|
+
expect(planCephAuthEntity({ ...PROPS, mount: 'talos-c2' }, OUTPUT)).toEqual({
|
|
57
|
+
action: 'replace',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('identityMoved', () => {
|
|
63
|
+
test('undefined output is never a move — a brand-new row', () => {
|
|
64
|
+
expect(identityMoved(PROPS, undefined)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the same entity and mount is not a move', () => {
|
|
68
|
+
expect(identityMoved(PROPS, OUTPUT)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a different entity, or a different mount, is a move', () => {
|
|
72
|
+
expect(identityMoved({ ...PROPS, entity: 'client.k8s-other' }, OUTPUT)).toBe(true);
|
|
73
|
+
expect(identityMoved({ ...PROPS, mount: 'talos-c2' }, OUTPUT)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('capsEqual', () => {
|
|
78
|
+
test('true for the same three fields, false if any one differs', () => {
|
|
79
|
+
expect(capsEqual(CAPS, { ...CAPS })).toBe(true);
|
|
80
|
+
expect(capsEqual(CAPS, { ...CAPS, mon: 'profile rbd pool=other' })).toBe(false);
|
|
81
|
+
expect(capsEqual(CAPS, { ...CAPS, osd: 'profile rbd pool=other' })).toBe(false);
|
|
82
|
+
expect(capsEqual(CAPS, { ...CAPS, mgr: 'profile rbd pool=other' })).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('bao paths', () => {
|
|
87
|
+
test('the CLI-style path has no `data/` segment; the HTTP path does', () => {
|
|
88
|
+
expect(cephCliPath('talos-c1', 'client.k8s-rbd')).toBe('talos-c1/ceph/client.k8s-rbd');
|
|
89
|
+
expect(cephDataPath('talos-c1', 'client.k8s-rbd')).toBe('talos-c1/data/ceph/client.k8s-rbd');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a trailing slash on the mount is trimmed', () => {
|
|
93
|
+
expect(cephCliPath('talos-c1/', 'client.k8s-rbd')).toBe('talos-c1/ceph/client.k8s-rbd');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('attributesOf', () => {
|
|
98
|
+
test('carries the fingerprint given, never anything shaped like a key', () => {
|
|
99
|
+
const attrs = attributesOf(PROPS, 'deadbeef', 'mon-a.example.test');
|
|
100
|
+
expect(attrs).toEqual(OUTPUT);
|
|
101
|
+
expect(Object.keys(attrs)).not.toContain('key');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Ceph.AuthEntity`'s props/attributes shapes, the OpenBao paths it writes to, and the pure plan
|
|
3
|
+
* function `diff` uses. Design: docs/plans/2026-09-26-ceph-mon-transport.md, "`Ceph.AuthEntity`
|
|
4
|
+
* (K-A4)".
|
|
5
|
+
*
|
|
6
|
+
* ⛔ `entity` AND `mount` ARE IDENTITY — WHICH MEANS A CHANGE TO EITHER IS `replace`, NEVER
|
|
7
|
+
* `update`. The original header here argued a different entity/mount is simply a different
|
|
8
|
+
* resource id in the consuming stack, so this family would never need `replace`. That assumed
|
|
9
|
+
* the id and the identity move together; nothing enforces that they do — a declaration can keep
|
|
10
|
+
* its Alchemy id and edit `entity` or `mount` in place. LAND red team (2026-09-26), CONFIRMED:
|
|
11
|
+
* with `output` still naming the OLD entity, comparing caps alone answered `update`, and
|
|
12
|
+
* reconcile then ran `auth get`/`auth caps` against the NEW entity — a foreign or unrelated
|
|
13
|
+
* live entity — because `output !== undefined` skipped the "never adopted" refusal (which only
|
|
14
|
+
* fires when state is absent). `identityMoved` below is the guard, mirroring the
|
|
15
|
+
* `rename-identity.ts` pattern `Bao.SshRole` and its siblings use for the same shape of bug —
|
|
16
|
+
* except this family cannot ask `judgeMove`'s occupancy question at plan time (that would ssh),
|
|
17
|
+
* so there is no `replace`-onto-an-occupied-identity refusal here; reconcile's own
|
|
18
|
+
* `neverAdopted` check is what catches a moved identity landing on a live foreign entity,
|
|
19
|
+
* because a `replace`'s new generation always reconciles with `output: undefined`.
|
|
20
|
+
*/
|
|
21
|
+
import { trimTrailingSlashes } from '../openbao/mount-path.ts';
|
|
22
|
+
import type { CephCaps } from './ceph-auth-parse.ts';
|
|
23
|
+
|
|
24
|
+
export type { CephCaps };
|
|
25
|
+
|
|
26
|
+
export type CephAuthEntityProps = {
|
|
27
|
+
/** `client.k8s-<name>` — bounded by ceph-argv.ts's allowlist, checked on every transport call. */
|
|
28
|
+
readonly entity: string;
|
|
29
|
+
readonly caps: CephCaps;
|
|
30
|
+
/** TB4 mon ssh destinations, tried in this order (D1). At least one. */
|
|
31
|
+
readonly nodes: readonly string[];
|
|
32
|
+
/** OpenBao KV-v2 mount holding Talos/Ceph material, e.g. `talos-c1` (D2). */
|
|
33
|
+
readonly mount: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CephAuthEntityAttributes = {
|
|
37
|
+
readonly entity: string;
|
|
38
|
+
readonly caps: CephCaps;
|
|
39
|
+
/** `sha256(key)`, hex — never the key itself. */
|
|
40
|
+
readonly fingerprint: string;
|
|
41
|
+
/** CLI-style path (`<mount>/ceph/<entity>`) — safe to persist and log; the key is not here. */
|
|
42
|
+
readonly baoPath: string;
|
|
43
|
+
/** Which mon actually answered the write that produced this row. Never key material. */
|
|
44
|
+
readonly node: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const capsEqual = (a: CephCaps, b: CephCaps): boolean =>
|
|
48
|
+
a.mon === b.mon && a.osd === b.osd && a.mgr === b.mgr;
|
|
49
|
+
|
|
50
|
+
/** The CLI-style path an operator's `bao kv get` would use — what `baoPath` records. */
|
|
51
|
+
export const cephCliPath = (mount: string, entity: string): string =>
|
|
52
|
+
`${trimTrailingSlashes(mount)}/ceph/${entity}`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The raw KV-v2 HTTP API path `../openbao/bao-http.ts`'s `baoRead`/`baoWrite` take — the CLI path
|
|
56
|
+
* with `data/` inserted, the same segment `bao kv get`/`bao kv put` insert themselves
|
|
57
|
+
* (`../talos/credentials.ts`'s header has the shipped-default bug this convention avoids: never
|
|
58
|
+
* include `data/` in the CLI-style path itself, only here).
|
|
59
|
+
*/
|
|
60
|
+
export const cephDataPath = (mount: string, entity: string): string =>
|
|
61
|
+
`${trimTrailingSlashes(mount)}/data/ceph/${entity}`;
|
|
62
|
+
|
|
63
|
+
export type CephAuthEntityPlan = { readonly action: 'noop' | 'update' | 'replace' };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether `output` (if any) names a different entity/mount than `news` declares — the one
|
|
67
|
+
* question both `planCephAuthEntity` and `reconcileCephAuthEntity`'s guard ask, so the comparison
|
|
68
|
+
* lives in exactly one place (rename-identity.ts's own header: "one description of which object,
|
|
69
|
+
* not twelve copies of the check").
|
|
70
|
+
*/
|
|
71
|
+
export const identityMoved = (
|
|
72
|
+
news: Pick<CephAuthEntityProps, 'entity' | 'mount'>,
|
|
73
|
+
output: CephAuthEntityAttributes | undefined,
|
|
74
|
+
): boolean =>
|
|
75
|
+
output !== undefined &&
|
|
76
|
+
(output.entity !== news.entity || output.baoPath !== cephCliPath(news.mount, news.entity));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ⛔ STATE ONLY, NO SSH — the design's own rule for this family ("a plan never elevates"; diff and
|
|
80
|
+
* verify compare props against state, never the live cluster). `output === undefined` returns
|
|
81
|
+
* `undefined`: a brand-new row plans as a create the same way every other family in this kit
|
|
82
|
+
* does, and reconcile is where the actual ssh work — and the key material — ever appears.
|
|
83
|
+
* A moved identity plans `replace` before caps are even considered: `defaultRemovalPolicy:
|
|
84
|
+
* 'retain'` means the old generation is simply left alone (Apply.ts skips its delete under
|
|
85
|
+
* retain), and the new generation reconciles with `output: undefined`, which is exactly the
|
|
86
|
+
* "never adopted" path if the new identity already has a live entity.
|
|
87
|
+
*/
|
|
88
|
+
export const planCephAuthEntity = (
|
|
89
|
+
news: CephAuthEntityProps,
|
|
90
|
+
output: CephAuthEntityAttributes | undefined,
|
|
91
|
+
): CephAuthEntityPlan | undefined => {
|
|
92
|
+
if (output === undefined) return undefined;
|
|
93
|
+
if (identityMoved(news, output)) return { action: 'replace' };
|
|
94
|
+
return capsEqual(news.caps, output.caps) ? { action: 'noop' } : { action: 'update' };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const attributesOf = (
|
|
98
|
+
props: Pick<CephAuthEntityProps, 'caps' | 'entity' | 'mount'>,
|
|
99
|
+
fingerprint: string,
|
|
100
|
+
node: string,
|
|
101
|
+
): CephAuthEntityAttributes => ({
|
|
102
|
+
baoPath: cephCliPath(props.mount, props.entity),
|
|
103
|
+
caps: props.caps,
|
|
104
|
+
entity: props.entity,
|
|
105
|
+
fingerprint,
|
|
106
|
+
node,
|
|
107
|
+
});
|