@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,82 @@
1
+ /**
2
+ * K-A4 finding 5 / decision 65 (2026-09-26): the design was amended to let the observe step read
3
+ * `auth get`'s unfiltered stdout on every reconcile, not only on create, with no node-side shell
4
+ * filter — accepted because the key is dropped in memory before anything is logged, returned,
5
+ * stored or put in an error. Split from ceph-auth-reconcile.test.ts (which already covers the
6
+ * happy-path create/no-op/drift secrecy checks) to keep that file under the house line cap; this
7
+ * file is the failure-path proof the decision's own text calls for.
8
+ *
9
+ * ⛔ THE MALFORMED STDOUT MUST ACTUALLY FAIL `JSON.parse`. LAND red team (2026-09-26), CONFIRMED:
10
+ * an earlier version of this file fed a WELL-FORMED JSON entry that was merely missing `caps` —
11
+ * a different, never-vulnerable error path (`parseCaps` never even looks at `key`) — so it never
12
+ * exercised the line that used to interpolate the parse failure's own message. The two shapes
13
+ * below (ceph-key-leak-assert.ts) are ones that genuinely throw inside `JSON.parse`.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { type Seen, run, withFake } from '../openbao/fake-bao.ts';
17
+ import {
18
+ KEY_SHAPED_SENTINEL,
19
+ leaksSliceOf,
20
+ trailingCommaKeyStdout,
21
+ unquotedKeyStdout,
22
+ } from './ceph-key-leak-assert.ts';
23
+ import { reconcileCephAuthEntity } from './ceph-auth-reconcile.ts';
24
+ import type { CephAuthEntityProps } from './ceph-auth-form.ts';
25
+ import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
26
+
27
+ const ENTITY = 'client.k8s-rbd';
28
+ const MON = 'mon-a.example.test';
29
+ const CAPS = {
30
+ mgr: 'profile rbd pool=k8s-rbd',
31
+ mon: 'profile rbd',
32
+ osd: 'profile rbd pool=k8s-rbd',
33
+ };
34
+ const PROPS: CephAuthEntityProps = { caps: CAPS, entity: ENTITY, mount: 'talos-c1', nodes: [MON] };
35
+ const okReply = () => ({ status: 200 });
36
+
37
+ const reconcileOn = async (stdout: string) => {
38
+ const lines: string[] = [];
39
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(stdout)] });
40
+ let caught: unknown;
41
+ let seen: readonly Seen[] = [];
42
+ await withFake(okReply, async (bao) => {
43
+ try {
44
+ await run(
45
+ { BAO_ADDR: bao.address },
46
+ reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: (l) => lines.push(l) }),
47
+ );
48
+ } catch (cause) {
49
+ caught = cause;
50
+ }
51
+ seen = bao.seen;
52
+ });
53
+ return { caught, lines, writes: seen.filter((s) => s.method !== 'GET') };
54
+ };
55
+
56
+ describe('a malformed present observation', () => {
57
+ test('a well-formed entry missing `caps` never leaks the key it carried (parseCaps never sees it)', async () => {
58
+ const { caught, lines, writes } = await reconcileOn(
59
+ JSON.stringify([{ entity: ENTITY, key: KEY_SHAPED_SENTINEL }]),
60
+ );
61
+ expect(caught).toBeInstanceOf(Error);
62
+ expect(leaksSliceOf(String(caught))).toBe(false);
63
+ expect(writes).toHaveLength(0);
64
+ expect(leaksSliceOf(lines.join('\n'))).toBe(false);
65
+ });
66
+
67
+ for (const [shape, stdout] of [
68
+ ['a bare (unquoted) key token', unquotedKeyStdout(ENTITY)],
69
+ ['a trailing comma after the entry', trailingCommaKeyStdout(ENTITY)],
70
+ ] as const) {
71
+ test(`unparseable stdout via ${shape} never leaks a slice of the key, through the full reconcile loop`, async () => {
72
+ // Decision 65 accepts reading `auth get`'s unfiltered stdout on every reconcile only
73
+ // because the key never survives past the parse — including when the parse ITSELF fails on
74
+ // input that (unlike a well-formed-but-incomplete entry) actually contains the key's bytes.
75
+ const { caught, lines, writes } = await reconcileOn(stdout);
76
+ expect(caught).toBeInstanceOf(Error);
77
+ expect(leaksSliceOf(String(caught))).toBe(false);
78
+ expect(writes).toHaveLength(0);
79
+ expect(leaksSliceOf(lines.join('\n'))).toBe(false);
80
+ });
81
+ }
82
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The observe -> ensure -> sync loop against fakeCephDial + fakeBao — no ssh, no real vault.
3
+ * Mon-transport doc acceptance tests #2 (the reconcile loop itself) and #3 (secret handling). The
4
+ * failure-path half of #3 — a malformed `auth get` reply that still carries a key — is
5
+ * ceph-auth-reconcile-secrecy.test.ts, split out to keep this file under the house line cap.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { sha256 } from '../openbao/digest.ts';
9
+ import { type Reply, type Seen, run, withFake } from '../openbao/fake-bao.ts';
10
+ import { leaksSliceOf } from './ceph-key-leak-assert.ts';
11
+ import { reconcileCephAuthEntity } from './ceph-auth-reconcile.ts';
12
+ import type { CephAuthEntityAttributes, CephAuthEntityProps } from './ceph-auth-form.ts';
13
+ import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
14
+
15
+ const ENTITY = 'client.k8s-rbd';
16
+ const MON = 'mon-a.example.test';
17
+ const CAPS = {
18
+ mgr: 'profile rbd pool=k8s-rbd',
19
+ mon: 'profile rbd',
20
+ osd: 'profile rbd pool=k8s-rbd',
21
+ };
22
+ const OTHER_CAPS = { ...CAPS, osd: 'profile rbd pool=other' };
23
+ const KEY = 'fake-ceph-key-not-a-real-secret';
24
+ const keyring = (caps = CAPS) => JSON.stringify([{ caps, entity: ENTITY, key: KEY }]);
25
+ const ABSENT = { exitCode: 2, stderr: 'Error ENOENT: not found', stdout: '' };
26
+ const HEALTHY_QUORUM = '{"quorum":[0,1,2]}';
27
+
28
+ const PROPS: CephAuthEntityProps = { caps: CAPS, entity: ENTITY, mount: 'talos-c1', nodes: [MON] };
29
+ const PRIOR: CephAuthEntityAttributes = {
30
+ baoPath: 'talos-c1/ceph/client.k8s-rbd',
31
+ caps: CAPS,
32
+ entity: ENTITY,
33
+ fingerprint: sha256(KEY),
34
+ node: MON,
35
+ };
36
+
37
+ const okReply = (): Reply => ({ status: 200 });
38
+ const writesOf = (seen: readonly Seen[]) => seen.filter((s) => s.method !== 'GET');
39
+ const argvOf = (seen: readonly { readonly argv: readonly string[] }[]) =>
40
+ seen.map((c) => c.argv.slice(1, 3));
41
+
42
+ describe('create of absent', () => {
43
+ test('a preflight write, get-or-create, the real write, one quorum check, key never in the attributes', async () => {
44
+ const lines: string[] = [];
45
+ const fake = fakeCephDial({
46
+ [MON]: [
47
+ { kind: 'result', result: ABSENT },
48
+ fakeCephOk(keyring()),
49
+ fakeCephOk(HEALTHY_QUORUM),
50
+ ],
51
+ });
52
+ await withFake(okReply, async (bao) => {
53
+ const attrs = await run(
54
+ { BAO_ADDR: bao.address },
55
+ reconcileCephAuthEntity(PROPS, undefined, {
56
+ dial: fake.dial,
57
+ log: (line) => lines.push(line),
58
+ }),
59
+ );
60
+ expect(attrs).toEqual(PRIOR);
61
+ expect(leaksSliceOf(JSON.stringify(attrs), KEY)).toBe(false);
62
+
63
+ // ⛔ The preflight (K-A4 finding 2) proves the path is writable BEFORE anything is minted —
64
+ // see ceph-auth-reconcile.ts's header. Both writes land at the same path; only the second
65
+ // carries the key.
66
+ const writes = writesOf(bao.seen);
67
+ expect(writes).toHaveLength(2);
68
+ expect(writes[0]?.path).toBe('/v1/talos-c1/data/ceph/client.k8s-rbd');
69
+ expect(leaksSliceOf(writes[0]?.body ?? '', KEY)).toBe(false);
70
+ expect(writes[1]?.path).toBe('/v1/talos-c1/data/ceph/client.k8s-rbd');
71
+ expect(writes[1]?.body).toContain(KEY);
72
+
73
+ expect(argvOf(fake.seen)).toEqual([
74
+ ['auth', 'get'],
75
+ ['auth', 'get-or-create'],
76
+ ['quorum_status', '-f'],
77
+ ]);
78
+ for (const call of fake.seen)
79
+ expect(leaksSliceOf(JSON.stringify(call.argv), KEY)).toBe(false);
80
+ // Decision 65 (LAND finding 5): the create path's own `auth get` observe also reads the
81
+ // keyring's unfiltered stdout — this proves the runner's log line never carries it either.
82
+ expect(leaksSliceOf(lines.join('\n'), KEY)).toBe(false);
83
+ });
84
+ });
85
+
86
+ test('a vault write refused before anything is minted leaves the mon untouched', async () => {
87
+ const fake = fakeCephDial({ [MON]: [{ kind: 'result', result: ABSENT }] });
88
+ const refused = () => ({ json: { errors: ['permission denied'] }, status: 403 });
89
+ await withFake(refused, async (bao) => {
90
+ await expect(
91
+ run(
92
+ { BAO_ADDR: bao.address },
93
+ reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
94
+ ),
95
+ ).rejects.toThrow();
96
+ // Only `auth get` (the observe) reached the mon — the preflight failed before get-or-create.
97
+ expect(argvOf(fake.seen)).toEqual([['auth', 'get']]);
98
+ });
99
+ });
100
+ });
101
+
102
+ describe('a second reconcile, caps unchanged', () => {
103
+ test('zero bao writes, zero quorum checks — the observe alone decides', async () => {
104
+ // Decision 65 (LAND finding 5): this `auth get` reads the keyring's unfiltered stdout too —
105
+ // prove the key never survives into attrs or the per-call log line, same as the create path.
106
+ const lines: string[] = [];
107
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(keyring())] });
108
+ await withFake(okReply, async (bao) => {
109
+ const attrs = await run(
110
+ { BAO_ADDR: bao.address },
111
+ reconcileCephAuthEntity(PROPS, PRIOR, { dial: fake.dial, log: (line) => lines.push(line) }),
112
+ );
113
+ expect(attrs).toEqual(PRIOR);
114
+ expect(leaksSliceOf(JSON.stringify(attrs), KEY)).toBe(false);
115
+ expect(writesOf(bao.seen)).toHaveLength(0);
116
+ expect(fake.seen).toHaveLength(1);
117
+ expect(leaksSliceOf(lines.join('\n'), KEY)).toBe(false);
118
+ });
119
+ });
120
+ });
121
+
122
+ describe('caps drift', () => {
123
+ test('runs exactly auth caps, never get-or-create; the fingerprint is reused, not reminted', async () => {
124
+ // Same decision-65 guarantee: the drifted `auth get` reply carries the key in its stdout too.
125
+ const lines: string[] = [];
126
+ const drifted: CephAuthEntityAttributes = { ...PRIOR, caps: OTHER_CAPS };
127
+ const fake = fakeCephDial({
128
+ [MON]: [fakeCephOk(keyring(OTHER_CAPS)), fakeCephOk(HEALTHY_QUORUM)],
129
+ });
130
+ await withFake(okReply, async (bao) => {
131
+ const attrs = await run(
132
+ { BAO_ADDR: bao.address },
133
+ reconcileCephAuthEntity(PROPS, drifted, {
134
+ dial: fake.dial,
135
+ log: (line) => lines.push(line),
136
+ }),
137
+ );
138
+ expect(attrs.caps).toEqual(CAPS);
139
+ expect(attrs.fingerprint).toBe(PRIOR.fingerprint);
140
+ expect(leaksSliceOf(JSON.stringify(attrs), KEY)).toBe(false);
141
+ expect(writesOf(bao.seen)).toHaveLength(0);
142
+ expect(argvOf(fake.seen)).toEqual([
143
+ ['auth', 'get'],
144
+ ['auth', 'caps'],
145
+ ['quorum_status', '-f'],
146
+ ]);
147
+ expect(leaksSliceOf(lines.join('\n'), KEY)).toBe(false);
148
+ });
149
+ });
150
+ });
151
+
152
+ describe('a down anchor', () => {
153
+ test('every mon failing at the transport layer is an error, never "absent", and nothing is created', async () => {
154
+ const fake = fakeCephDial({});
155
+ await withFake(okReply, async (bao) => {
156
+ await expect(
157
+ run(
158
+ { BAO_ADDR: bao.address },
159
+ reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
160
+ ),
161
+ ).rejects.toThrow(/never treated as absent/);
162
+ expect(writesOf(bao.seen)).toHaveLength(0);
163
+ });
164
+ });
165
+ });
166
+
167
+ describe('never adopted', () => {
168
+ test('a live entity present with no prior state is refused outright, never taken over', async () => {
169
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(keyring())] });
170
+ await withFake(okReply, async (bao) => {
171
+ await expect(
172
+ run(
173
+ { BAO_ADDR: bao.address },
174
+ reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
175
+ ),
176
+ ).rejects.toThrow(/never adopted/);
177
+ expect(writesOf(bao.seen)).toHaveLength(0);
178
+ expect(fake.seen).toHaveLength(1);
179
+ });
180
+ });
181
+ });
182
+
183
+ describe('a moved identity (K-A4 finding 1)', () => {
184
+ test('an entity edited in place is refused before any ssh call, never re-capped as a takeover', async () => {
185
+ const moved: CephAuthEntityProps = { ...PROPS, entity: 'client.k8s-other' };
186
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(keyring())] });
187
+ await withFake(okReply, async (bao) => {
188
+ await expect(
189
+ run(
190
+ { BAO_ADDR: bao.address },
191
+ reconcileCephAuthEntity(moved, PRIOR, { dial: fake.dial, log: () => {} }),
192
+ ),
193
+ ).rejects.toThrow(/identity moved/);
194
+ // Refused before the transport is ever touched — no `auth get`, no `auth caps`.
195
+ expect(fake.seen).toHaveLength(0);
196
+ expect(writesOf(bao.seen)).toHaveLength(0);
197
+ });
198
+ });
199
+
200
+ test('a mount change alone is the same identity move', async () => {
201
+ const moved: CephAuthEntityProps = { ...PROPS, mount: 'talos-c2' };
202
+ const fake = fakeCephDial({});
203
+ await withFake(okReply, async (bao) => {
204
+ await expect(
205
+ run(
206
+ { BAO_ADDR: bao.address },
207
+ reconcileCephAuthEntity(moved, PRIOR, { dial: fake.dial, log: () => {} }),
208
+ ),
209
+ ).rejects.toThrow(/identity moved/);
210
+ expect(fake.seen).toHaveLength(0);
211
+ });
212
+ });
213
+ });
214
+
215
+ describe('a degraded quorum after a write fails the row', () => {
216
+ test('the create is reported as failed even though the ceph write itself landed', async () => {
217
+ const fake = fakeCephDial({
218
+ [MON]: [
219
+ { kind: 'result', result: ABSENT },
220
+ fakeCephOk(keyring()),
221
+ fakeCephOk('{"quorum":[]}'),
222
+ ],
223
+ });
224
+ await withFake(okReply, async (bao) => {
225
+ await expect(
226
+ run(
227
+ { BAO_ADDR: bao.address },
228
+ reconcileCephAuthEntity(PROPS, undefined, { dial: fake.dial, log: () => {} }),
229
+ ),
230
+ ).rejects.toThrow(/degraded/);
231
+ });
232
+ });
233
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The observe -> ensure -> sync loop `Ceph.AuthEntity`'s reconcile runs (mon-transport doc,
3
+ * `Ceph.AuthEntity`). Split from auth-entity.ts so the loop itself — the part a fake-runner test
4
+ * actually exercises — is a plain function, the same split ceph-pool.ts and ssh-role.ts use.
5
+ *
6
+ * ⛔ NEVER ADOPTS. A live entity found present while this stack holds no attributes for it is
7
+ * refused outright, `--adopt` included — the generic `refuseTakeover` (../ownership/adopt.ts)
8
+ * is deliberately NOT used here, because it honours `--adopt` and this family's own design says
9
+ * plainly: "Existing auth entities are recorded as observed inventory only — never adopted:
10
+ * adopting one would mean reading and fingerprinting its key, and a key is only ever handled for
11
+ * an entity this stack owns."
12
+ * ⛔ THE OBSERVE STEP READS `auth get`'S UNFILTERED STDOUT ON EVERY RECONCILE (decision 65,
13
+ * 2026-09-26, amending the design after LAND finding 5 — no node-side filter narrows it first).
14
+ * The key that stdout carries is dropped in the same breath: `parseAuthGet` has no `key` field in
15
+ * its return type at all, so there is no variable here ever holding it past that call. Only `auth
16
+ * get-or-create`, run once on the create branch, yields a `key` value, and it goes straight to
17
+ * OpenBao — never assigned to a variable this function returns, logs, or puts in the attributes
18
+ * it hands back — `attributesOf` takes a fingerprint, never a key.
19
+ * ★ CAPS DRIFT NEVER RE-MINTS THE KEY. `auth caps` changes only what an entity may do; the returned
20
+ * attributes reuse `output.fingerprint` unchanged.
21
+ * ⛔ A MOVED IDENTITY IS REFUSED HERE TOO, BELT AND SUSPENDERS WITH `planCephAuthEntity`'s
22
+ * `replace`. `isResolved(news)` in auth-entity.ts's `diff` can defer to the engine's own
23
+ * `update` fallback while any prop is still an Output (rename.ts's own header documents this
24
+ * exact Alchemy behaviour for every other family this pattern protects) — a case in which
25
+ * `planCephAuthEntity` never even runs. Reached with `output` naming a different entity/mount
26
+ * than `props`, this refuses before any ssh call, the same shape of guard `guardRename`
27
+ * (openbao/rename-identity.ts) runs first in every `Bao.*` reconcile.
28
+ */
29
+ import * as Effect from 'effect/Effect';
30
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
31
+ import { baoWrite } from '../openbao/bao-http.ts';
32
+ import { sha256 } from '../openbao/digest.ts';
33
+ import { authCapsArgv, authGetArgv, authGetOrCreateArgv } from './ceph-argv.ts';
34
+ import { parseAuthGet, parseAuthGetOrCreate } from './ceph-auth-parse.ts';
35
+ import {
36
+ type CephAuthEntityAttributes,
37
+ type CephAuthEntityProps,
38
+ attributesOf,
39
+ capsEqual,
40
+ cephDataPath,
41
+ identityMoved,
42
+ } from './ceph-auth-form.ts';
43
+ import { type CephTransportOptions, assertFreshQuorum, runCephCommand } from './ceph-transport.ts';
44
+
45
+ export type CephAuthDeps = Pick<CephTransportOptions, 'dial' | 'log'>;
46
+
47
+ const asError = (cause: unknown): Error =>
48
+ cause instanceof Error ? cause : new Error(String(cause));
49
+
50
+ const runCeph = (transport: CephTransportOptions, argv: readonly string[]) =>
51
+ Effect.tryPromise({ catch: asError, try: () => runCephCommand(transport, argv) });
52
+
53
+ const syncQuorum = (transport: CephTransportOptions) =>
54
+ Effect.tryPromise({ catch: asError, try: () => assertFreshQuorum(transport) });
55
+
56
+ const neverAdopted = (entity: string): Error =>
57
+ new Error(
58
+ `Ceph.AuthEntity ${entity}: already exists on the mon cluster and this stack holds no state ` +
59
+ 'for it. This family is never adopted — not even under --adopt — because taking it over ' +
60
+ 'would mean fingerprinting a key this stack did not mint. Remove it by hand first, or bring ' +
61
+ 'it under management through the coordinator’s own read-only inventory process.',
62
+ );
63
+
64
+ const movedIdentity = (from: string, to: string): Error =>
65
+ new Error(
66
+ `Ceph.AuthEntity: planned as an update, but its identity moved from ${from} to ${to}. ` +
67
+ `Acting on ${to} now, with ${from}'s attributes, could re-cap an entity this generation ` +
68
+ 'never minted. Nothing was sent to any mon. Run the deploy again: once entity/mount fully ' +
69
+ 'resolve at plan time this plans as a replace instead.',
70
+ );
71
+
72
+ export const reconcileCephAuthEntity = (
73
+ props: CephAuthEntityProps,
74
+ output: CephAuthEntityAttributes | undefined,
75
+ deps: CephAuthDeps,
76
+ ): Effect.Effect<CephAuthEntityAttributes, Error, HttpClient.HttpClient> =>
77
+ Effect.gen(function* () {
78
+ if (identityMoved(props, output))
79
+ return yield* Effect.fail(movedIdentity(output?.entity ?? '', props.entity));
80
+
81
+ const transport: CephTransportOptions = { nodes: props.nodes, ...deps };
82
+
83
+ const observed = yield* runCeph(transport, authGetArgv(props.entity)).pipe(
84
+ Effect.map((result) => parseAuthGet(result, props.entity)),
85
+ );
86
+
87
+ if (observed.kind === 'absent') {
88
+ // ⛔ THE VAULT WRITE IS CHECKED BEFORE ANYTHING IS MINTED. LAND red team (2026-09-26),
89
+ // CONFIRMED: minting first and writing second means a write refused by policy (the likely
90
+ // first-deploy shape — the `data/ceph/*` write grant landing after this stack's first
91
+ // run) strands a key that only ever existed in memory. The next reconcile's `auth get`
92
+ // then finds the entity present, `output` is still undefined, and `neverAdopted` refuses
93
+ // it — permanently, since `auth del` is refused too (D3). A placeholder write proves the
94
+ // path is writable first; nothing has touched the mon if it fails, so a retry starts
95
+ // clean. ⚠️ KV-v2 keeps this placeholder as a version in the secret's history (no version
96
+ // is ever skipped) — harmless, since it never carries anything key-shaped, but visible to
97
+ // `bao kv get -version=N` until the mount's `max_versions` rolls it off.
98
+ yield* baoWrite('POST', cephDataPath(props.mount, props.entity), {
99
+ data: { pending: true },
100
+ });
101
+ const result = yield* runCeph(transport, authGetOrCreateArgv(props.entity, props.caps));
102
+ const created = parseAuthGetOrCreate(result, props.entity);
103
+ // ⛔ THE ONE PLACE THE KEY CROSSES INTO A BAO CALL — as the HTTP request body's JSON, never
104
+ // argv, never a temp file. `created.key` is not referenced again after this.
105
+ yield* baoWrite('POST', cephDataPath(props.mount, props.entity), {
106
+ data: { caps: created.caps, key: created.key },
107
+ });
108
+ yield* syncQuorum(transport);
109
+ return attributesOf(props, sha256(created.key), result.node);
110
+ }
111
+
112
+ if (output === undefined) return yield* Effect.fail(neverAdopted(props.entity));
113
+
114
+ if (capsEqual(observed.caps, props.caps)) return output;
115
+
116
+ const result = yield* runCeph(transport, authCapsArgv(props.entity, props.caps));
117
+ yield* syncQuorum(transport);
118
+ return attributesOf(props, output.fingerprint, result.node);
119
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Structural guard on the named config-option list itself (mon-transport doc acceptance test #3):
3
+ * the list contains none of the lockout classes, and it starts empty (D-C1).
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { NAMED_CONFIG_OPTIONS, isLockoutConfigOption } from './ceph-config-options.ts';
7
+
8
+ describe('NAMED_CONFIG_OPTIONS', () => {
9
+ test('starts empty', () => {
10
+ expect(NAMED_CONFIG_OPTIONS.size).toBe(0);
11
+ });
12
+
13
+ test('contains none of the lockout classes, whatever gets added later', () => {
14
+ const bad = [...NAMED_CONFIG_OPTIONS].filter((name) => isLockoutConfigOption(name));
15
+ expect(bad).toEqual([]);
16
+ });
17
+ });
18
+
19
+ describe('isLockoutConfigOption', () => {
20
+ test.each([
21
+ 'mon_host',
22
+ 'public_network',
23
+ 'cluster_network',
24
+ 'public_addr',
25
+ 'cluster_addr',
26
+ 'auth_cluster_required',
27
+ 'auth_service_required',
28
+ 'auth_client_required',
29
+ 'keyring',
30
+ 'mgr_keyring',
31
+ 'keyring_path',
32
+ 'key',
33
+ 'mon_data_key',
34
+ ])('flags %s', (name) => {
35
+ expect(isLockoutConfigOption(name)).toBe(true);
36
+ });
37
+
38
+ test.each(['mon_allow_pool_delete', 'osd_pool_default_size', 'mon_max_pg_per_osd'])(
39
+ 'does not flag an ordinary tuning option like %s',
40
+ (name) => {
41
+ expect(isLockoutConfigOption(name)).toBe(false);
42
+ },
43
+ );
44
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The named Ceph config-option list — the only options `ceph config get/set/rm` may ever touch
3
+ * through the mon-command transport (ceph-argv.ts's `configProblem`). Design:
4
+ * docs/plans/2026-09-26-ceph-config-options.md, D-C1.
5
+ *
6
+ * ⛔ THE LIST STARTS EMPTY, AND EACH ADDITION IS ITS OWN REVIEWED PR LINE. Nothing in K-A4 adds to
7
+ * it — `CephConfigOption` (K-D) is a separate, later family; this file exists now only so the
8
+ * transport's allowlist has something to bound `config` against from day one, per the accepted
9
+ * design ("config get/set for the named option list which starts EMPTY").
10
+ * ⛔ NEVER ADD ONE OF THE LOCKOUT CLASSES BELOW. `mon_host`, `public_network`, `cluster_network`,
11
+ * `public_addr`, `cluster_addr`, every `auth_*_required` and every `key`/`keyring`-bearing
12
+ * option re-address the mons or de-authenticate the whole cluster — cutting every VM disk — and
13
+ * `isLockoutConfigOption` is a structural guard a test runs against this file's own list, not a
14
+ * suggestion for the reviewer of the next PR line.
15
+ * ⛔ NEVER ADD A SECRET-VALUED OPTION. mgr module options can hold passwords (REASONED — no live
16
+ * cluster to measure this against yet); a secret-bearing setting belongs in a vault-backed flow,
17
+ * never a `ceph config set` argv, which lands in the runner's log and the node's sudo journal.
18
+ */
19
+
20
+ /** Starts empty. Add one reviewed option name per PR line, never a batch. */
21
+ export const NAMED_CONFIG_OPTIONS: ReadonlySet<string> = new Set([]);
22
+
23
+ const LOCKOUT_EXACT: ReadonlySet<string> = new Set([
24
+ 'mon_host',
25
+ 'public_network',
26
+ 'cluster_network',
27
+ 'public_addr',
28
+ 'cluster_addr',
29
+ ]);
30
+
31
+ /** `auth_*_required` (cephx enforcement) and anything key/keyring-shaped, either side of `_`. */
32
+ const LOCKOUT_PATTERN = /^auth_\w*_required$|(^|_)key(ring)?($|_)/;
33
+
34
+ /** Whether `name` is a class this transport must never read a value for, adopt, or write. */
35
+ export const isLockoutConfigOption = (name: string): boolean =>
36
+ LOCKOUT_EXACT.has(name) || LOCKOUT_PATTERN.test(name);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared by the two test files that prove `Ceph.AuthEntity` never leaks a minted key through a
3
+ * parse-error message: ceph-auth-parse.test.ts (direct parser calls) and
4
+ * ceph-auth-reconcile-secrecy.test.ts (through the full reconcile loop). Split out so the leak
5
+ * check and the malformed-stdout fixtures are identical in both places — the property being
6
+ * proved (decision 65) is the same one, and two hand-copied versions are exactly the kind of thing
7
+ * that quietly drifts apart.
8
+ *
9
+ * ⛔ TEST-ONLY. Nothing in `src/` imports this outside a `.test.ts` file.
10
+ * ★ NO HYPHEN IN THE SENTINEL. LAND red team (2026-09-26), CONFIRMED: a JSON lexer's "unexpected
11
+ * identifier" error for a bare (unquoted) token stops at the first character outside
12
+ * `[A-Za-z0-9_$]`, so a hyphenated sentinel (`sentinel-ceph-key-...`) truncates to its first
13
+ * word — `.not.toContain(SENTINEL)` on the FULL string then never fires even when that first
14
+ * word leaked. Cephx keys are base64 (`[A-Za-z0-9+/=]`, no hyphen), so this sentinel is shaped
15
+ * the same way, and every check here compares 8-character slices, not full-string containment.
16
+ */
17
+
18
+ /** A cephx-shaped sentinel: looks like a real minted key, no hyphen anywhere to truncate on. */
19
+ export const KEY_SHAPED_SENTINEL = 'AQDsentinelKEYsentinelKEY0123456789abcd==';
20
+
21
+ /** True if any 8-character window of `key` shows up anywhere in `message`. */
22
+ export const leaksSliceOf = (message: string, key: string = KEY_SHAPED_SENTINEL): boolean => {
23
+ for (let start = 0; start + 8 <= key.length; start += 1) {
24
+ if (message.includes(key.slice(start, start + 8))) return true;
25
+ }
26
+ return false;
27
+ };
28
+
29
+ /** `auth get`/`auth get-or-create` stdout with the key left as a bare (unquoted) JSON token. */
30
+ export const unquotedKeyStdout = (entity: string, key: string = KEY_SHAPED_SENTINEL): string =>
31
+ `[{"entity":"${entity}","key":${key}}]`;
32
+
33
+ /** The same shape with a trailing comma after the entry — a different malformed-JSON path. */
34
+ export const trailingCommaKeyStdout = (entity: string, key: string = KEY_SHAPED_SENTINEL): string =>
35
+ `[{"entity":"${entity}","key":"${key}"},]`;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The design doc's own Risks section (docs/plans/2026-09-26-ceph-mon-transport.md) says this
3
+ * family "gets a structural test over its own source (no output-logging)". LAND red team
4
+ * (2026-09-26): no such test existed — the fake-runner tests proved the key never reaches a log
5
+ * line for the SHAPES they happened to exercise, but nothing proved a future call site could not
6
+ * add a new one that does. This is that test, the same grep-gate shape
7
+ * `talos/secrets-gate.test.ts` runs for PEM/bootstrap-token material.
8
+ *
9
+ * ★ A POSITIVE CONTROL WOULD DEFEAT THE PURPOSE HERE, same as secrets-gate.test.ts: there is no
10
+ * "real" case where a hit should be found, so this only has a negative case.
11
+ * ⚠️ THIS IS A TEXT SCAN, NOT A TYPE CHECKER. It cannot prove no code path ever COULD log output —
12
+ * only that today's source doesn't. ceph-transport.ts's own header comment carries the actual
13
+ * invariant ("ONLY ARGV IS EVER LOGGED"); this test is the regression guard for it.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { readFileSync, readdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ const CEPH_DIR = import.meta.dir;
20
+
21
+ /** This family's own production source — never a test file, never the fake dial's test double. */
22
+ const productionFiles = () =>
23
+ readdirSync(CEPH_DIR)
24
+ .filter(
25
+ (name) => name.endsWith('.ts') && !name.includes('.test.') && name !== 'fake-ceph-dial.ts',
26
+ )
27
+ .map((name) => join(CEPH_DIR, name));
28
+
29
+ describe('no output-logging — structural test over this family’s own source', () => {
30
+ test('no console.* call anywhere — logging goes only through the injected `log` sink', () => {
31
+ for (const file of productionFiles()) {
32
+ const text = readFileSync(file, 'utf8');
33
+ expect(text).not.toMatch(/\bconsole\.\w+\(/);
34
+ }
35
+ });
36
+
37
+ test('every call to the injected `log` sink logs the attempted argv line only, never stdout/stderr/a key', () => {
38
+ let sawACall = false;
39
+ for (const file of productionFiles()) {
40
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
41
+ if (!/\blog\(/.test(line)) continue;
42
+ sawACall = true;
43
+ expect(line).not.toMatch(/\.stdout\b/);
44
+ expect(line).not.toMatch(/\.stderr\b/);
45
+ expect(line).not.toMatch(/\bkey\b/i);
46
+ }
47
+ }
48
+ // ⛔ A GUARD AGAINST THE GUARD GOING QUIET. If this ever finds zero call sites (the log call
49
+ // renamed, the file moved), the two checks above would trivially pass having proven nothing.
50
+ expect(sawACall).toBe(true);
51
+ });
52
+ });