@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.
Files changed (207) hide show
  1. package/README.md +10 -8
  2. package/dist/ceph/auth-entity.d.ts +34 -0
  3. package/dist/ceph/auth-entity.d.ts.map +1 -0
  4. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  5. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  6. package/dist/ceph/ceph-argv.d.ts +19 -0
  7. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  8. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  9. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  10. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  12. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  14. package/dist/ceph/ceph-config-options.d.ts +23 -0
  15. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  16. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  18. package/dist/ceph/ceph-transport.d.ts +37 -0
  19. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  20. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  22. package/dist/ceph/index.d.ts +13 -0
  23. package/dist/ceph/index.d.ts.map +1 -0
  24. package/dist/ceph/index.js +1609 -0
  25. package/dist/ceph/index.js.map +38 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +15 -6
  29. package/dist/proxmox/index.js.map +3 -3
  30. package/dist/proxmox/qemu-volume.d.ts +12 -2
  31. package/dist/proxmox/qemu-volume.d.ts.map +1 -1
  32. package/dist/talos/credentials-write.d.ts +46 -0
  33. package/dist/talos/credentials-write.d.ts.map +1 -0
  34. package/dist/talos/credentials.d.ts +51 -9
  35. package/dist/talos/credentials.d.ts.map +1 -1
  36. package/dist/talos/fake-process.d.ts +56 -0
  37. package/dist/talos/fake-process.d.ts.map +1 -0
  38. package/dist/talos/index.d.ts +19 -4
  39. package/dist/talos/index.d.ts.map +1 -1
  40. package/dist/talos/index.js +357 -173
  41. package/dist/talos/index.js.map +16 -10
  42. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  43. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  44. package/dist/talos/kubeconfig.d.ts +41 -23
  45. package/dist/talos/kubeconfig.d.ts.map +1 -1
  46. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  47. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  48. package/dist/talos/machine-config-poll.d.ts +43 -0
  49. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  50. package/dist/talos/machine-config-read.d.ts +49 -0
  51. package/dist/talos/machine-config-read.d.ts.map +1 -0
  52. package/dist/talos/resource.d.ts +18 -0
  53. package/dist/talos/resource.d.ts.map +1 -1
  54. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  55. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  56. package/dist/talos/talos-bootstrap.d.ts +87 -0
  57. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  58. package/dist/talos/talos-cluster-health.d.ts +43 -1
  59. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  60. package/dist/talos/talos-errors.d.ts +68 -0
  61. package/dist/talos/talos-errors.d.ts.map +1 -0
  62. package/dist/talos/talos-machine-config.d.ts +51 -15
  63. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  64. package/dist/talos/talosctl.d.ts +7 -0
  65. package/dist/talos/talosctl.d.ts.map +1 -1
  66. package/dist/talos/values.d.ts +23 -3
  67. package/dist/talos/values.d.ts.map +1 -1
  68. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  69. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  70. package/dist/unifi/acl-rule-form.d.ts +90 -0
  71. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  72. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  73. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  74. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  75. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  76. package/dist/unifi/acl-rule.d.ts +49 -0
  77. package/dist/unifi/acl-rule.d.ts.map +1 -0
  78. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  79. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  80. package/dist/unifi/dns-policy-form.d.ts +97 -0
  81. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  82. package/dist/unifi/dns-policy.d.ts +55 -0
  83. package/dist/unifi/dns-policy.d.ts.map +1 -0
  84. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  85. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  86. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  87. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  88. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  89. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  90. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  91. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  92. package/dist/unifi/firewall-policy.d.ts +57 -0
  93. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  94. package/dist/unifi/index.d.ts +7 -0
  95. package/dist/unifi/index.d.ts.map +1 -1
  96. package/dist/unifi/index.js +588 -3
  97. package/dist/unifi/index.js.map +24 -4
  98. package/dist/unifi/paginate.d.ts +0 -12
  99. package/dist/unifi/paginate.d.ts.map +1 -1
  100. package/dist/unifi/providers.d.ts +1 -1
  101. package/dist/unifi/providers.d.ts.map +1 -1
  102. package/dist/unifi/scan-source.d.ts +11 -0
  103. package/dist/unifi/scan-source.d.ts.map +1 -0
  104. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  105. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  106. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  107. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  108. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  109. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  110. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  111. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  112. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  113. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  114. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  115. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  116. package/docs/unifi-acl-rule.md +122 -0
  117. package/docs/unifi-dns-policy.md +75 -0
  118. package/docs/unifi-firewall-policy.md +109 -0
  119. package/docs/unifi-traffic-matching-list.md +82 -0
  120. package/docs/unifi-wifi.md +120 -0
  121. package/docs/unifi.md +10 -5
  122. package/package.json +6 -2
  123. package/src/ceph/auth-entity.ts +74 -0
  124. package/src/ceph/ceph-argv-config.ts +78 -0
  125. package/src/ceph/ceph-argv.test.ts +226 -0
  126. package/src/ceph/ceph-argv.ts +189 -0
  127. package/src/ceph/ceph-auth-form.test.ts +103 -0
  128. package/src/ceph/ceph-auth-form.ts +107 -0
  129. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  130. package/src/ceph/ceph-auth-parse.ts +121 -0
  131. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  132. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  133. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  134. package/src/ceph/ceph-config-options.test.ts +44 -0
  135. package/src/ceph/ceph-config-options.ts +36 -0
  136. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  137. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  138. package/src/ceph/ceph-transport.test.ts +125 -0
  139. package/src/ceph/ceph-transport.ts +117 -0
  140. package/src/ceph/fake-ceph-dial.ts +55 -0
  141. package/src/ceph/index.ts +12 -0
  142. package/src/index.ts +1 -0
  143. package/src/proxmox/qemu-volume.test.ts +153 -4
  144. package/src/proxmox/qemu-volume.ts +71 -14
  145. package/src/talos/credentials-write.test.ts +81 -0
  146. package/src/talos/credentials-write.ts +95 -0
  147. package/src/talos/credentials.test.ts +157 -0
  148. package/src/talos/credentials.ts +160 -90
  149. package/src/talos/fake-process.ts +94 -0
  150. package/src/talos/index.ts +39 -4
  151. package/src/talos/kubeconfig-attrs.ts +55 -0
  152. package/src/talos/kubeconfig.test.ts +256 -0
  153. package/src/talos/kubeconfig.ts +170 -90
  154. package/src/talos/machine-config-fixtures.ts +86 -0
  155. package/src/talos/machine-config-poll.test.ts +57 -0
  156. package/src/talos/machine-config-poll.ts +82 -0
  157. package/src/talos/machine-config-read.ts +118 -0
  158. package/src/talos/resource.ts +19 -0
  159. package/src/talos/secrets-gate.test.ts +63 -0
  160. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  161. package/src/talos/talos-bootstrap.test.ts +218 -0
  162. package/src/talos/talos-bootstrap.ts +151 -45
  163. package/src/talos/talos-cluster-health.test.ts +143 -0
  164. package/src/talos/talos-cluster-health.ts +91 -28
  165. package/src/talos/talos-errors.ts +85 -0
  166. package/src/talos/talos-machine-config.test.ts +189 -0
  167. package/src/talos/talos-machine-config.ts +102 -88
  168. package/src/talos/talosctl.ts +8 -0
  169. package/src/talos/values.test.ts +34 -6
  170. package/src/talos/values.ts +41 -7
  171. package/src/unifi/acl-rule-drift.ts +57 -0
  172. package/src/unifi/acl-rule-form.ts +135 -0
  173. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  174. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  175. package/src/unifi/acl-rule-ordering.ts +70 -0
  176. package/src/unifi/acl-rule.test.ts +183 -0
  177. package/src/unifi/acl-rule.ts +61 -0
  178. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  179. package/src/unifi/dns-policy-drift.ts +48 -0
  180. package/src/unifi/dns-policy-form.ts +142 -0
  181. package/src/unifi/dns-policy.test.ts +154 -0
  182. package/src/unifi/dns-policy.ts +67 -0
  183. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  184. package/src/unifi/firewall-policy-drift.ts +55 -0
  185. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  186. package/src/unifi/firewall-policy-form.ts +146 -0
  187. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  188. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  189. package/src/unifi/firewall-policy-ordering.ts +87 -0
  190. package/src/unifi/firewall-policy.test.ts +183 -0
  191. package/src/unifi/firewall-policy.ts +73 -0
  192. package/src/unifi/index.ts +63 -0
  193. package/src/unifi/paginate.test.ts +24 -0
  194. package/src/unifi/paginate.ts +15 -3
  195. package/src/unifi/providers.ts +19 -1
  196. package/src/unifi/scan-source.ts +64 -0
  197. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  198. package/src/unifi/traffic-matching-list-form.ts +77 -0
  199. package/src/unifi/traffic-matching-list.test.ts +202 -0
  200. package/src/unifi/traffic-matching-list.ts +78 -0
  201. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  202. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  203. package/src/unifi/wifi-broadcast-form.ts +211 -0
  204. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  205. package/src/unifi/wifi-broadcast.test.ts +155 -0
  206. package/src/unifi/wifi-broadcast.ts +90 -0
  207. 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
+ };