@homeflare/alchemy 0.39.0 → 0.40.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/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 +1609 -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/proxmox/index.js +15 -6
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- 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/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 +6 -2
- 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/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- 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,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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing against fixture stdout — REASONED shapes (ceph-auth-parse.ts's header), so these fixtures
|
|
3
|
+
* are this PR's stand-in for a live cluster, not a measurement.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { leaksSliceOf, trailingCommaKeyStdout, unquotedKeyStdout } from './ceph-key-leak-assert.ts';
|
|
7
|
+
import { parseAuthGet, parseAuthGetOrCreate, parseQuorumStatus } from './ceph-auth-parse.ts';
|
|
8
|
+
|
|
9
|
+
const ENTITY = 'client.k8s-rbd';
|
|
10
|
+
const CAPS = {
|
|
11
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
12
|
+
mon: 'profile rbd',
|
|
13
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
14
|
+
};
|
|
15
|
+
const KEYRING_JSON = JSON.stringify([
|
|
16
|
+
{ caps: CAPS, entity: ENTITY, key: 'fake-ceph-key-not-a-real-secret' },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
describe('parseAuthGet', () => {
|
|
20
|
+
test('present: returns caps only — the parsed value has no key field at all', () => {
|
|
21
|
+
const observed = parseAuthGet({ exitCode: 0, stderr: '', stdout: KEYRING_JSON }, ENTITY);
|
|
22
|
+
expect(observed).toEqual({ caps: CAPS, kind: 'present' });
|
|
23
|
+
expect(Object.keys(observed)).not.toContain('key');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('absent: ENOENT in stderr, never mistaken for an error', () => {
|
|
27
|
+
const observed = parseAuthGet(
|
|
28
|
+
{ exitCode: 2, stderr: 'Error ENOENT: failed to find client.k8s-rbd in keyring', stdout: '' },
|
|
29
|
+
ENTITY,
|
|
30
|
+
);
|
|
31
|
+
expect(observed).toEqual({ kind: 'absent' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a nonzero exit without ENOENT propagates as an error, never as absent', () => {
|
|
35
|
+
expect(() =>
|
|
36
|
+
parseAuthGet({ exitCode: 1, stderr: 'Error EACCES: permission denied', stdout: '' }, ENTITY),
|
|
37
|
+
).toThrow(/EACCES/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('unparseable stdout is an error, never absent', () => {
|
|
41
|
+
expect(() => parseAuthGet({ exitCode: 0, stderr: '', stdout: 'not json' }, ENTITY)).toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a JSON array with no matching entity is an error', () => {
|
|
45
|
+
const other = JSON.stringify([{ caps: CAPS, entity: 'client.k8s-other', key: 'x' }]);
|
|
46
|
+
expect(() => parseAuthGet({ exitCode: 0, stderr: '', stdout: other }, ENTITY)).toThrow(
|
|
47
|
+
/no matching entry/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an entry missing a caps field is an error', () => {
|
|
52
|
+
const bad = JSON.stringify([{ entity: ENTITY, key: 'x' }]);
|
|
53
|
+
expect(() => parseAuthGet({ exitCode: 0, stderr: '', stdout: bad }, ENTITY)).toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Decision 65 (LAND finding 5): `auth get` is read unfiltered on every reconcile, so every
|
|
57
|
+
// failure path below feeds it a key-shaped sentinel and proves no 8-character SLICE of it ever
|
|
58
|
+
// reaches the thrown error — not just the whole string. LAND red team (2026-09-26), CONFIRMED:
|
|
59
|
+
// a hyphenated sentinel and a whole-string `.not.toContain` check both missed a real leak where
|
|
60
|
+
// `JSON.parse`'s own error message quoted a prefix of an unquoted or comma-adjacent key
|
|
61
|
+
// (ceph-key-leak-assert.ts has the measured detail).
|
|
62
|
+
test('a malformed present entry never lets even a slice of the key it carried leak', () => {
|
|
63
|
+
const bad = JSON.stringify([{ entity: ENTITY, key: 'sentinel-ceph-key-must-never-leak' }]); // no caps
|
|
64
|
+
let caught: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parseAuthGet({ exitCode: 0, stderr: '', stdout: bad }, ENTITY);
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
caught = cause;
|
|
69
|
+
}
|
|
70
|
+
expect(caught).toBeInstanceOf(Error);
|
|
71
|
+
expect(leaksSliceOf(String(caught), 'sentinel-ceph-key-must-never-leak')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a nonzero exit never echoes stdout, even a slice of a key it somehow carried', () => {
|
|
75
|
+
const stdout = unquotedKeyStdout(ENTITY);
|
|
76
|
+
let caught: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parseAuthGet({ exitCode: 13, stderr: 'Error EPERM: refused', stdout }, ENTITY);
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
caught = cause;
|
|
81
|
+
}
|
|
82
|
+
expect(String(caught)).toContain('EPERM');
|
|
83
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
for (const [shape, stdout] of [
|
|
87
|
+
['a bare (unquoted) key token', unquotedKeyStdout(ENTITY)],
|
|
88
|
+
['a trailing comma after the entry', trailingCommaKeyStdout(ENTITY)],
|
|
89
|
+
] as const) {
|
|
90
|
+
test(`unparseable stdout via ${shape} never leaks a slice of the key, from parseAuthGet`, () => {
|
|
91
|
+
let caught: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parseAuthGet({ exitCode: 0, stderr: '', stdout }, ENTITY);
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
caught = cause;
|
|
96
|
+
}
|
|
97
|
+
expect(caught).toBeInstanceOf(Error);
|
|
98
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test(`unparseable stdout via ${shape} never leaks a slice of the key, from parseAuthGetOrCreate`, () => {
|
|
102
|
+
let caught: unknown;
|
|
103
|
+
try {
|
|
104
|
+
parseAuthGetOrCreate({ exitCode: 0, stderr: '', stdout }, ENTITY);
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
caught = cause;
|
|
107
|
+
}
|
|
108
|
+
expect(caught).toBeInstanceOf(Error);
|
|
109
|
+
expect(leaksSliceOf(String(caught))).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('parseAuthGetOrCreate', () => {
|
|
115
|
+
test('returns caps and the key, on success', () => {
|
|
116
|
+
const created = parseAuthGetOrCreate({ exitCode: 0, stderr: '', stdout: KEYRING_JSON }, ENTITY);
|
|
117
|
+
expect(created).toEqual({ caps: CAPS, key: 'fake-ceph-key-not-a-real-secret' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('never reads a nonzero exit as absent — always an error', () => {
|
|
121
|
+
expect(() =>
|
|
122
|
+
parseAuthGetOrCreate(
|
|
123
|
+
{ exitCode: 22, stderr: 'Error EINVAL: caps do not match', stdout: '' },
|
|
124
|
+
ENTITY,
|
|
125
|
+
),
|
|
126
|
+
).toThrow(/EINVAL/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('parseQuorumStatus', () => {
|
|
131
|
+
test('healthy: a non-empty quorum array', () => {
|
|
132
|
+
expect(parseQuorumStatus('{"quorum":[0,1,2],"quorum_names":["a","b","c"]}')).toEqual({
|
|
133
|
+
healthy: true,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('degraded: an empty quorum array', () => {
|
|
138
|
+
expect(parseQuorumStatus('{"quorum":[]}')).toEqual({
|
|
139
|
+
healthy: false,
|
|
140
|
+
reason: 'no non-empty `quorum` array in the quorum_status output',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('degraded: no quorum field at all', () => {
|
|
145
|
+
expect(parseQuorumStatus('{"election_epoch":5}').healthy).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('degraded: not even a JSON object', () => {
|
|
149
|
+
expect(parseQuorumStatus('[1,2,3]').healthy).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('degraded, never thrown: unparseable JSON fails closed instead of crashing the caller', () => {
|
|
153
|
+
expect(parseQuorumStatus('not json').healthy).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing for the three ceph `-f json` shapes `Ceph.AuthEntity` reads: `auth get`, `auth
|
|
3
|
+
* get-or-create` and `quorum_status`. Pure: no I/O, tested with fixture stdout
|
|
4
|
+
* (ceph-auth-parse.test.ts) — there is no live mon to capture real output from yet, so every shape
|
|
5
|
+
* below is REASONED from public Ceph documentation, not measured, and an unexpected shape is
|
|
6
|
+
* always an error, never a guess.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ `parseAuthGet`'S RETURN TYPE HAS NO `key` FIELD. Decision 65 (2026-09-26, LAND finding 5)
|
|
9
|
+
* amended the design to let the observe step read `auth get`'s unfiltered stdout on every
|
|
10
|
+
* reconcile, not only on create — but the key it carries is dropped in the same breath:
|
|
11
|
+
* `parseKeyringEntry` reads it into a local binding and `parseAuthGet` never puts it in the
|
|
12
|
+
* object it returns. Not a convention, a type: there is no field here to forget to strip.
|
|
13
|
+
* ⛔ `parseQuorumStatus` FAILS CLOSED. A JSON parse error, a missing `quorum` array or an empty one
|
|
14
|
+
* all answer `{ healthy: false }`; nothing here ever calls an unparseable answer healthy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type CephCaps = { readonly mon: string; readonly osd: string; readonly mgr: string };
|
|
18
|
+
|
|
19
|
+
export type CephExecOutcome = {
|
|
20
|
+
readonly exitCode: number;
|
|
21
|
+
readonly stdout: string;
|
|
22
|
+
readonly stderr: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
26
|
+
typeof value === 'object' && value !== null;
|
|
27
|
+
|
|
28
|
+
const parseCaps = (value: unknown, entity: string): CephCaps => {
|
|
29
|
+
if (!isRecord(value)) throw new Error(`ceph auth: ${entity}'s entry has no caps object`);
|
|
30
|
+
const { mon, osd, mgr } = value;
|
|
31
|
+
if (typeof mon !== 'string' || typeof osd !== 'string' || typeof mgr !== 'string') {
|
|
32
|
+
throw new Error(`ceph auth: ${entity}'s caps are missing a mon, osd or mgr string`);
|
|
33
|
+
}
|
|
34
|
+
return { mgr, mon, osd };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* REASONED shape (public `ceph auth get -f json` / `auth get-or-create -f json` docs): a JSON
|
|
39
|
+
* array with one object per entity, `{entity, key, caps: {mon, osd, mgr}}`.
|
|
40
|
+
*/
|
|
41
|
+
const parseKeyringEntry = (
|
|
42
|
+
stdout: string,
|
|
43
|
+
entity: string,
|
|
44
|
+
): { readonly caps: CephCaps; readonly key: string } => {
|
|
45
|
+
let parsed: unknown;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(stdout);
|
|
48
|
+
} catch {
|
|
49
|
+
// ⛔ NEVER INTERPOLATE THE CATCH VALUE. LAND red team (2026-09-26), CONFIRMED: both Bun's and
|
|
50
|
+
// Node's `JSON.parse` errors quote a slice of the offending input — a bare (unquoted) or
|
|
51
|
+
// comma-adjacent key in this stdout put a slice of a real cephx key straight into this
|
|
52
|
+
// thrown error (measured: median 32/40 chars of a 1000-key sample). `stdout` carries the
|
|
53
|
+
// key on every reconcile since decision 65, so this message names only what failed, never
|
|
54
|
+
// any part of what it was reading.
|
|
55
|
+
throw new Error(`ceph auth ${entity}: stdout did not parse as JSON`);
|
|
56
|
+
}
|
|
57
|
+
const row = Array.isArray(parsed)
|
|
58
|
+
? parsed.find((entry) => isRecord(entry) && entry['entity'] === entity)
|
|
59
|
+
: undefined;
|
|
60
|
+
if (!isRecord(row)) throw new Error(`ceph auth ${entity}: no matching entry in the JSON output`);
|
|
61
|
+
const { key } = row;
|
|
62
|
+
if (typeof key !== 'string' || key.trim() === '') {
|
|
63
|
+
throw new Error(`ceph auth ${entity}: the entry has no key field`);
|
|
64
|
+
}
|
|
65
|
+
return { caps: parseCaps(row['caps'], entity), key };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CephAuthObserved =
|
|
69
|
+
| { readonly kind: 'present'; readonly caps: CephCaps }
|
|
70
|
+
| { readonly kind: 'absent' };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ⚠️ ABSENCE IS RECOGNISED ONLY BY `ENOENT` IN STDERR (REASONED from `ceph auth get`'s documented
|
|
74
|
+
* error text). Any other nonzero exit — a permission problem, a malformed entity ceph itself
|
|
75
|
+
* refused, a mon mid-election — propagates as an error, never as "absent": the transient-read
|
|
76
|
+
* test every family in this kit carries.
|
|
77
|
+
*/
|
|
78
|
+
export const parseAuthGet = (result: CephExecOutcome, entity: string): CephAuthObserved => {
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
if (result.stderr.includes('ENOENT')) return { kind: 'absent' };
|
|
81
|
+
throw new Error(`ceph auth get ${entity} failed: ${result.stderr.trim().slice(0, 300)}`);
|
|
82
|
+
}
|
|
83
|
+
const { caps } = parseKeyringEntry(result.stdout, entity);
|
|
84
|
+
return { caps, kind: 'present' };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* ⚠️ `get-or-create` NEVER READS AS ABSENT — REASONED: it either creates, returns the existing
|
|
89
|
+
* entity unchanged, or fails outright (e.g. an existing entity with different caps). A nonzero
|
|
90
|
+
* exit here is always an error.
|
|
91
|
+
*/
|
|
92
|
+
export const parseAuthGetOrCreate = (
|
|
93
|
+
result: CephExecOutcome,
|
|
94
|
+
entity: string,
|
|
95
|
+
): { readonly caps: CephCaps; readonly key: string } => {
|
|
96
|
+
if (result.exitCode !== 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`ceph auth get-or-create ${entity} failed: ${result.stderr.trim().slice(0, 300)}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return parseKeyringEntry(result.stdout, entity);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type CephQuorum =
|
|
105
|
+
| { readonly healthy: true }
|
|
106
|
+
| { readonly healthy: false; readonly reason: string };
|
|
107
|
+
|
|
108
|
+
/** REASONED shape (public `ceph quorum_status -f json` docs): an object with a `quorum` array. */
|
|
109
|
+
export const parseQuorumStatus = (stdout: string): CephQuorum => {
|
|
110
|
+
let parsed: unknown;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(stdout);
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
return { healthy: false, reason: `stdout did not parse as JSON (${String(cause)})` };
|
|
115
|
+
}
|
|
116
|
+
if (!isRecord(parsed)) return { healthy: false, reason: 'stdout was not a JSON object' };
|
|
117
|
+
const quorum = parsed['quorum'];
|
|
118
|
+
return Array.isArray(quorum) && quorum.length > 0
|
|
119
|
+
? { healthy: true }
|
|
120
|
+
: { healthy: false, reason: 'no non-empty `quorum` array in the quorum_status output' };
|
|
121
|
+
};
|