@homeflare/alchemy 0.39.0 → 0.41.0

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