@homeflare/alchemy 0.11.0 → 0.13.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 (290) hide show
  1. package/README.md +3 -3
  2. package/dist/caddy/admin-guard.d.ts +1 -1
  3. package/dist/caddy/index.js.map +2 -2
  4. package/dist/cloudflare/client.d.ts +1 -1
  5. package/dist/cloudflare/index.js.map +2 -2
  6. package/dist/cloudflare/lock-rules.d.ts +1 -1
  7. package/dist/forgejo/branch-protection-form.d.ts +1 -1
  8. package/dist/forgejo/branch-protection.d.ts +1 -1
  9. package/dist/forgejo/index.js.map +10 -10
  10. package/dist/forgejo/org-actions-secrets.d.ts +1 -1
  11. package/dist/forgejo/org-label.d.ts +1 -1
  12. package/dist/forgejo/org-team.d.ts +1 -1
  13. package/dist/forgejo/repo-webhook.d.ts +1 -1
  14. package/dist/forgejo/repository.d.ts +2 -2
  15. package/dist/forgejo/repository.d.ts.map +1 -1
  16. package/dist/forgejo/resource.d.ts +2 -2
  17. package/dist/forgejo/team-member.d.ts +1 -1
  18. package/dist/openbao/auth-role.d.ts +1 -1
  19. package/dist/openbao/bao-http.d.ts +4 -4
  20. package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
  21. package/dist/openbao/cloudflare-role-form.d.ts +2 -2
  22. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
  23. package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
  24. package/dist/openbao/digest.d.ts +1 -1
  25. package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
  26. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
  27. package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
  28. package/dist/openbao/index.js.map +14 -14
  29. package/dist/openbao/mount.d.ts +1 -1
  30. package/dist/openbao/pki-role.d.ts +1 -1
  31. package/dist/openbao/policy.d.ts +2 -2
  32. package/dist/openbao/proxmox-role-form.d.ts +2 -2
  33. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  34. package/dist/openbao/proxmox-role.d.ts +10 -10
  35. package/dist/openbao/ssh-role.d.ts +3 -3
  36. package/dist/proxmox/acl.d.ts +2 -2
  37. package/dist/proxmox/acl.d.ts.map +1 -1
  38. package/dist/proxmox/alertmanager-body.d.ts +54 -0
  39. package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
  40. package/dist/proxmox/api-token-form.d.ts +2 -2
  41. package/dist/proxmox/api-token.d.ts +11 -11
  42. package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
  43. package/dist/proxmox/ceph-daemon.d.ts +19 -19
  44. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  45. package/dist/proxmox/ceph-flag.d.ts +5 -5
  46. package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
  47. package/dist/proxmox/ceph-fs.d.ts +16 -16
  48. package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
  49. package/dist/proxmox/ceph-osd.d.ts +5 -5
  50. package/dist/proxmox/ceph-pool-form.d.ts +13 -13
  51. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
  52. package/dist/proxmox/ceph-pool.d.ts +3 -3
  53. package/dist/proxmox/client.d.ts +2 -2
  54. package/dist/proxmox/credential-literals.d.ts +36 -0
  55. package/dist/proxmox/credential-literals.d.ts.map +1 -0
  56. package/dist/proxmox/credentials.d.ts +8 -8
  57. package/dist/proxmox/credentials.d.ts.map +1 -1
  58. package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
  59. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
  60. package/dist/proxmox/fake-pve.d.ts +5 -0
  61. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  62. package/dist/proxmox/firewall-alias-form.d.ts +1 -1
  63. package/dist/proxmox/firewall-alias.d.ts +1 -1
  64. package/dist/proxmox/generated/pbs.d.ts +279 -1
  65. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  66. package/dist/proxmox/generated/pve.d.ts +58 -1
  67. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  68. package/dist/proxmox/group.d.ts +8 -8
  69. package/dist/proxmox/ha-resource.d.ts +3 -2
  70. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  71. package/dist/proxmox/ha-rule-form.d.ts +5 -5
  72. package/dist/proxmox/ha-rule.d.ts +3 -3
  73. package/dist/proxmox/index.d.ts +14 -0
  74. package/dist/proxmox/index.d.ts.map +1 -1
  75. package/dist/proxmox/index.js +935 -223
  76. package/dist/proxmox/index.js.map +69 -56
  77. package/dist/proxmox/lease-cache.d.ts +4 -4
  78. package/dist/proxmox/lxc-harness.d.ts +1 -1
  79. package/dist/proxmox/lxc-props.d.ts +2 -2
  80. package/dist/proxmox/lxc.d.ts +3 -3
  81. package/dist/proxmox/members.d.ts +3 -3
  82. package/dist/proxmox/network-apply-read.d.ts +5 -5
  83. package/dist/proxmox/network-apply.d.ts +9 -9
  84. package/dist/proxmox/node-network-form.d.ts +6 -6
  85. package/dist/proxmox/node-network.d.ts +17 -17
  86. package/dist/proxmox/node-network.d.ts.map +1 -1
  87. package/dist/proxmox/notification-matcher-form.d.ts +100 -0
  88. package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
  89. package/dist/proxmox/notification-matcher.d.ts +46 -0
  90. package/dist/proxmox/notification-matcher.d.ts.map +1 -0
  91. package/dist/proxmox/notification-target.d.ts +3 -2
  92. package/dist/proxmox/notification-target.d.ts.map +1 -1
  93. package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
  94. package/dist/proxmox/pbs-datastore.d.ts +10 -10
  95. package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
  96. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
  97. package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
  98. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
  99. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
  100. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
  101. package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
  102. package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
  103. package/dist/proxmox/pbs-notification-target.d.ts +123 -0
  104. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
  105. package/dist/proxmox/pbs-prune-job.d.ts +2 -2
  106. package/dist/proxmox/pbs-sync-job.d.ts +4 -4
  107. package/dist/proxmox/pbs-verify-job.d.ts +1 -1
  108. package/dist/proxmox/pool.d.ts +4 -4
  109. package/dist/proxmox/provision-baseline.d.ts +74 -0
  110. package/dist/proxmox/provision-baseline.d.ts.map +1 -0
  111. package/dist/proxmox/provision-bootstrap.d.ts +33 -0
  112. package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
  113. package/dist/proxmox/provision-cli-fake.d.ts +38 -0
  114. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
  115. package/dist/proxmox/provision-declare.d.ts +25 -0
  116. package/dist/proxmox/provision-declare.d.ts.map +1 -0
  117. package/dist/proxmox/replication-job.d.ts +9 -8
  118. package/dist/proxmox/replication-job.d.ts.map +1 -1
  119. package/dist/proxmox/resource.d.ts +6 -5
  120. package/dist/proxmox/resource.d.ts.map +1 -1
  121. package/dist/proxmox/sdn-apply-read.d.ts +7 -7
  122. package/dist/proxmox/sdn-apply.d.ts +1 -1
  123. package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
  124. package/dist/proxmox/sdn-subnet.d.ts +5 -5
  125. package/dist/proxmox/sdn-vnet.d.ts +5 -6
  126. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  127. package/dist/proxmox/storage.d.ts +4 -3
  128. package/dist/proxmox/storage.d.ts.map +1 -1
  129. package/dist/proxmox/user.d.ts +3 -2
  130. package/dist/proxmox/user.d.ts.map +1 -1
  131. package/dist/proxmox/values.d.ts +1 -1
  132. package/dist/proxmox/write-only.d.ts +35 -0
  133. package/dist/proxmox/write-only.d.ts.map +1 -0
  134. package/dist/proxmox/zfs-pool-write.d.ts +1 -1
  135. package/dist/proxmox/zfs-pool.d.ts +5 -5
  136. package/dist/talos/credentials.d.ts +1 -1
  137. package/dist/talos/credentials.d.ts.map +1 -1
  138. package/dist/talos/index.js.map +1 -1
  139. package/dist/verify/fake-engine.d.ts +5 -0
  140. package/dist/verify/fake-engine.d.ts.map +1 -1
  141. package/docs/adopt-verify.md +21 -5
  142. package/docs/adopted-deploys.md +9 -1
  143. package/docs/ownership.md +26 -1
  144. package/docs/pbs-alertmanager-body.md +73 -0
  145. package/docs/pbs-notifications.md +163 -0
  146. package/docs/provision-baseline.md +106 -0
  147. package/docs/proxmox-lxc-adopt.md +2 -2
  148. package/docs/proxmox-lxc.md +5 -4
  149. package/docs/proxmox.md +9 -0
  150. package/package.json +1 -1
  151. package/src/caddy/admin-guard.ts +1 -1
  152. package/src/caddy/local-admin.ts +1 -1
  153. package/src/cloudflare/client.ts +1 -1
  154. package/src/cloudflare/lock-rules.ts +1 -1
  155. package/src/forgejo/branch-protection-form.ts +1 -1
  156. package/src/forgejo/branch-protection.ts +1 -1
  157. package/src/forgejo/client.ts +1 -1
  158. package/src/forgejo/org-actions-secrets.ts +1 -1
  159. package/src/forgejo/org-label.ts +1 -1
  160. package/src/forgejo/org-team.ts +1 -1
  161. package/src/forgejo/repo-webhook.ts +1 -1
  162. package/src/forgejo/repository.ts +2 -2
  163. package/src/forgejo/resource.ts +2 -2
  164. package/src/forgejo/team-member.ts +1 -1
  165. package/src/openbao/README.md +4 -4
  166. package/src/openbao/auth-role.ts +1 -1
  167. package/src/openbao/bao-http.test.ts +2 -2
  168. package/src/openbao/bao-http.ts +6 -6
  169. package/src/openbao/bao-status.test.ts +1 -1
  170. package/src/openbao/bao-status.ts +1 -1
  171. package/src/openbao/cloudflare-group-scope.test.ts +1 -1
  172. package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
  173. package/src/openbao/cloudflare-permission-groups.ts +3 -3
  174. package/src/openbao/cloudflare-policy.ts +1 -1
  175. package/src/openbao/cloudflare-role-form.ts +2 -2
  176. package/src/openbao/cloudflare-roles-expand.ts +1 -1
  177. package/src/openbao/digest.ts +1 -1
  178. package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
  179. package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
  180. package/src/openbao/forgejo-bootstrap.ts +4 -4
  181. package/src/openbao/kubernetes-role-form.test.ts +5 -5
  182. package/src/openbao/mount.ts +1 -1
  183. package/src/openbao/pki-role.ts +1 -1
  184. package/src/openbao/policy-wire.test.ts +3 -3
  185. package/src/openbao/policy.ts +2 -2
  186. package/src/openbao/proxmox-role-form.ts +5 -5
  187. package/src/openbao/proxmox-role.ts +11 -11
  188. package/src/openbao/ssh-role.ts +3 -3
  189. package/src/proxmox/acl.ts +4 -4
  190. package/src/proxmox/adopt-noop.test.ts +6 -6
  191. package/src/proxmox/alertmanager-body.ts +125 -0
  192. package/src/proxmox/api-token-form.ts +4 -4
  193. package/src/proxmox/api-token.ts +13 -13
  194. package/src/proxmox/ceph-daemon-form.ts +3 -3
  195. package/src/proxmox/ceph-daemon.ts +20 -20
  196. package/src/proxmox/ceph-flag.ts +7 -7
  197. package/src/proxmox/ceph-fs-wire.ts +7 -7
  198. package/src/proxmox/ceph-fs.ts +18 -18
  199. package/src/proxmox/ceph-osd-tree.ts +16 -16
  200. package/src/proxmox/ceph-osd.ts +6 -6
  201. package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
  202. package/src/proxmox/ceph-pool-form.ts +13 -13
  203. package/src/proxmox/ceph-pool.ts +6 -6
  204. package/src/proxmox/client.test.ts +3 -3
  205. package/src/proxmox/client.ts +3 -3
  206. package/src/proxmox/credential-literals.test.ts +148 -0
  207. package/src/proxmox/credential-literals.ts +74 -0
  208. package/src/proxmox/credentials.test.ts +6 -8
  209. package/src/proxmox/credentials.ts +8 -8
  210. package/src/proxmox/fake-pbs-notify.ts +108 -0
  211. package/src/proxmox/fake-pve.ts +12 -2
  212. package/src/proxmox/firewall-alias-form.ts +1 -1
  213. package/src/proxmox/firewall-alias.ts +1 -1
  214. package/src/proxmox/generated/pbs.ts +330 -1
  215. package/src/proxmox/generated/pve.ts +63 -1
  216. package/src/proxmox/group.ts +10 -10
  217. package/src/proxmox/ha-resource.ts +3 -2
  218. package/src/proxmox/ha-rule-form.ts +5 -5
  219. package/src/proxmox/ha-rule.ts +3 -3
  220. package/src/proxmox/index.test.ts +10 -0
  221. package/src/proxmox/index.ts +32 -0
  222. package/src/proxmox/lease-cache.test.ts +19 -19
  223. package/src/proxmox/lease-cache.ts +4 -4
  224. package/src/proxmox/lxc-adopt.test.ts +3 -3
  225. package/src/proxmox/lxc-create-form.ts +1 -1
  226. package/src/proxmox/lxc-guards.test.ts +4 -4
  227. package/src/proxmox/lxc-harness.ts +3 -3
  228. package/src/proxmox/lxc-judge.test.ts +5 -5
  229. package/src/proxmox/lxc-lifecycle.ts +1 -1
  230. package/src/proxmox/lxc-props.ts +2 -2
  231. package/src/proxmox/lxc-read.test.ts +3 -3
  232. package/src/proxmox/lxc-strict-adopt.test.ts +1 -1
  233. package/src/proxmox/lxc-volume.test.ts +9 -9
  234. package/src/proxmox/lxc-volume.ts +1 -1
  235. package/src/proxmox/lxc-wire.ts +1 -1
  236. package/src/proxmox/lxc.ts +3 -3
  237. package/src/proxmox/members.test.ts +1 -1
  238. package/src/proxmox/members.ts +3 -3
  239. package/src/proxmox/metric-server-otel.ts +2 -2
  240. package/src/proxmox/network-apply-read.ts +10 -10
  241. package/src/proxmox/network-apply.ts +10 -10
  242. package/src/proxmox/node-network-form.ts +7 -7
  243. package/src/proxmox/node-network.ts +18 -18
  244. package/src/proxmox/notification-matcher-form.ts +221 -0
  245. package/src/proxmox/notification-matcher.test.ts +234 -0
  246. package/src/proxmox/notification-matcher.ts +76 -0
  247. package/src/proxmox/notification-target.ts +3 -2
  248. package/src/proxmox/pbs-datastore-guard.ts +1 -1
  249. package/src/proxmox/pbs-datastore.ts +10 -10
  250. package/src/proxmox/pbs-notification-matcher.ts +81 -0
  251. package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
  252. package/src/proxmox/pbs-notification-target-form.ts +229 -0
  253. package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
  254. package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
  255. package/src/proxmox/pbs-notification-target-wire.ts +112 -0
  256. package/src/proxmox/pbs-notification-target.test.ts +226 -0
  257. package/src/proxmox/pbs-notification-target.ts +139 -0
  258. package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
  259. package/src/proxmox/pbs-prune-job.ts +2 -2
  260. package/src/proxmox/pbs-sync-job.ts +4 -4
  261. package/src/proxmox/pbs-verify-job.ts +1 -1
  262. package/src/proxmox/pool.test.ts +2 -2
  263. package/src/proxmox/pool.ts +4 -4
  264. package/src/proxmox/provision-baseline.test.ts +87 -0
  265. package/src/proxmox/provision-baseline.ts +162 -0
  266. package/src/proxmox/provision-bootstrap.test.ts +132 -0
  267. package/src/proxmox/provision-bootstrap.ts +135 -0
  268. package/src/proxmox/provision-cli-fake.ts +217 -0
  269. package/src/proxmox/provision-declare.test.ts +111 -0
  270. package/src/proxmox/provision-declare.ts +103 -0
  271. package/src/proxmox/replication-job.ts +9 -8
  272. package/src/proxmox/resource.ts +9 -9
  273. package/src/proxmox/sdn-apply-read.test.ts +16 -16
  274. package/src/proxmox/sdn-apply-read.ts +7 -7
  275. package/src/proxmox/sdn-apply.ts +3 -3
  276. package/src/proxmox/sdn-subnet-form.ts +1 -1
  277. package/src/proxmox/sdn-subnet.ts +5 -5
  278. package/src/proxmox/sdn-vnet.ts +5 -6
  279. package/src/proxmox/storage.ts +4 -3
  280. package/src/proxmox/user.ts +3 -2
  281. package/src/proxmox/values.test.ts +2 -2
  282. package/src/proxmox/values.ts +1 -1
  283. package/src/proxmox/write-only.test.ts +75 -0
  284. package/src/proxmox/write-only.ts +106 -0
  285. package/src/proxmox/zfs-pool-write.ts +1 -1
  286. package/src/proxmox/zfs-pool.ts +6 -6
  287. package/src/talos/credentials.ts +3 -3
  288. package/src/talos/values.test.ts +9 -9
  289. package/src/verify/cli.test.ts +1 -1
  290. package/src/verify/fake-engine.ts +8 -1
@@ -11,13 +11,13 @@
11
11
  * `CephOsdProps` is a cycle on paper only: it is erased before anything runs.
12
12
  *
13
13
  * ⛔ `GET /nodes/{node}/ceph/osd` ANSWERS A TREE, NOT A LIST, AND IT IS THE WHOLE CLUSTER'S TREE.
14
- * MEASURED 2026-09-13: asked of n2 it answers `{"flags":…,"root":{"children":[…]}}` whose
15
- * leaves include osd.0 and osd.5 on n4; asked of n3 it answers the identical six leaves. The
14
+ * MEASURED 2026-09-13: asked of node-b it answers `{"flags":…,"root":{"children":[…]}}` whose
15
+ * leaves include osd.0 and osd.5 on node-d; asked of node-c it answers the identical six leaves. The
16
16
  * `{node}` in the path chooses WHO ANSWERS, not what is listed. A provider that read it as "the
17
- * OSDs on this node" would find osd.0 through n2 and then report it as living there.
17
+ * OSDs on this node" would find osd.0 through node-b and then report it as living there.
18
18
  *
19
19
  * ⛔ AND THE SINGLE-OBJECT PATH CANNOT BE USED INSTEAD — THIS WALK EXISTS BECAUSE IT LIES.
20
- * MEASURED: `GET /nodes/n2/ceph/osd/99`, an id with no OSD behind it, answers HTTP 200 with
20
+ * MEASURED: `GET /nodes/node-b/ceph/osd/99`, an id with no OSD behind it, answers HTTP 200 with
21
21
  * `[{"name":"metadata"},{"name":"lv-info"}]` — byte for byte what `…/osd/2` answers for a real
22
22
  * one. It is a directory index (its schema says `permissions: {"user":"all"}`), not a read of
23
23
  * the OSD. Point `PveSpec.path` at it and every declared OSD is "present": `diff` reports
@@ -25,18 +25,18 @@
25
25
  * stops this package recording objects that do not exist — passes on an OSD that was never
26
26
  * built. The only endpoint that can tell present from absent is the tree.
27
27
  *
28
- * ★ MEASURED ON TB4 ON 2026-09-13, `GET /nodes/n2/ceph/osd`. Six leaves, all `up` and `in`, Ceph
28
+ * ★ MEASURED ON C1 ON 2026-09-13, `GET /nodes/node-b/ceph/osd`. Six leaves, all `up` and `in`, Ceph
29
29
  * 20.2.2 tentacle:
30
30
  *
31
- * id name host device_class crush_weight reweight pgs status
32
- * 0 osd.0 n4 ssd 1.86299133300781 1 99 up
33
- * 5 osd.5 n4 ssd 1.86299133300781 1 94 up
34
- * 1 osd.1 n3 ssd 1.86299133300781 1 96 up
35
- * 4 osd.4 n3 ssd 1.81939697265625 1 97 up
36
- * 2 osd.2 n2 ssd 1.81939697265625 1 88 up
37
- * 3 osd.3 n2 ssd 1.81939697265625 1 105 up
31
+ * id name host device_class crush_weight reweight pgs status
32
+ * 0 osd.0 node-d ssd 1.86299133300781 1 99 up
33
+ * 5 osd.5 node-d ssd 1.86299133300781 1 94 up
34
+ * 1 osd.1 node-c ssd 1.86299133300781 1 96 up
35
+ * 4 osd.4 node-c ssd 1.81939697265625 1 97 up
36
+ * 2 osd.2 node-b ssd 1.81939697265625 1 88 up
37
+ * 3 osd.3 node-b ssd 1.81939697265625 1 105 up
38
38
  *
39
- * `{ node: 'n2', osdid: 2, host: 'n2', device_class: 'ssd' }` plans `noop` against that row.
39
+ * `{ node: 'node-b', osdid: 2, host: 'node-b', device_class: 'ssd' }` plans `noop` against that row.
40
40
  * That is the acceptance test this file was written to pass.
41
41
  *
42
42
  * ⚠️ EVERY OTHER COLUMN IS REPORTED AND NEVER COMPARED, FOR FOUR DIFFERENT REASONS. `pgs` is the
@@ -72,8 +72,8 @@ const branches = (node: CrushNode): CrushNode[] =>
72
72
  /**
73
73
  * Every OSD leaf under the tree root, at any depth.
74
74
  *
75
- * ⚠️ RECURSIVE BECAUSE THE TREE IS NOT THREE LEVELS DEEP BY LAW. TB4 measures as
76
- * root(`default`) -> host(n2|n3|n4) -> osd, but CRUSH admits datacenter, rack and chassis
75
+ * ⚠️ RECURSIVE BECAUSE THE TREE IS NOT THREE LEVELS DEEP BY LAW. C1 measures as
76
+ * root(`default`) -> host(node-b|node-c|node-d) -> osd, but CRUSH admits datacenter, rack and chassis
77
77
  * buckets between them, and an OSD created outside a host bucket hangs off the root. Reaching
78
78
  * in as `root.children[].children[]` would read this cluster correctly and answer "absent" —
79
79
  * i.e. "the replica is gone" — on the first cluster that has a rack in it.
@@ -149,7 +149,7 @@ export interface CephOsdAttributes {
149
149
  * ⚠️ THE TELEMETRY IS DELIBERATELY DROPPED, NOT FORGOTTEN. The leaf also carries `bytes_used`,
150
150
  * `percent_used`, `apply_latency_ms` and `commit_latency_ms`. MEASURED, AND THE MEASUREMENT SAYS
151
151
  * THE OPPOSITE OF WHAT YOU WOULD EXPECT: across two reads minutes apart, all four were identical
152
- * on every OSD, and both latencies read 0 — TB4 was idle, so nothing was caught in the act. That
152
+ * on every OSD, and both latencies read 0 — C1 was idle, so nothing was caught in the act. That
153
153
  * these move is therefore REASONED, from what they count, not observed. Alchemy persists
154
154
  * attributes; on a cluster doing work, keeping them would rewrite all six resources' state on
155
155
  * every deploy and record a number that is stale by the time anybody reads it. What is kept is
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * A PVE method with no permissions block is refused to every identity except `root@pam`, so an
13
13
  * OpenBao-minted `hf-provision@pve!…` token is answered 403 however wide its role — widening
14
- * `LXCProvisioner` cannot fix it. Compare the sibling families, which DO carry a check and
14
+ * the provision role cannot fix it. Compare the sibling families, which DO carry a check and
15
15
  * therefore do work with a token: `ceph/fs`, `ceph/mon` and `disks/zfs` all want `Sys.Modify`.
16
16
  *
17
17
  * ⚠️ SO THE WRITE PATH IS HONEST RATHER THAN USEFUL. It is written out properly in
@@ -19,7 +19,7 @@
19
19
  * operator running as root@pam. Through this package's credential it will 403, and a 403 is a
20
20
  * better answer than a `delete` that silently does nothing while the plan claims otherwise.
21
21
  *
22
- * ★ WHAT IT IS ACTUALLY FOR, THEN: ASSERTION. It turns "n2, n3 and n4 each hold two ssd OSDs"
22
+ * ★ WHAT IT IS ACTUALLY FOR, THEN: ASSERTION. It turns "node-b, node-c and node-d each hold two ssd OSDs"
23
23
  * into a line a plan checks, and it gives Ceph-backed resources something real to depend on —
24
24
  * read `osd.osdid` into an `rbd` storage's props and Alchemy orders that storage after the
25
25
  * assertion, so a stack can no longer report a healthy Ceph storage on a cluster whose OSDs are
@@ -53,8 +53,8 @@ export type { CephOsdAttributes };
53
53
 
54
54
  export interface CephOsdProps extends WithTarget {
55
55
  /**
56
- * Which node ANSWERS the read — not where the OSD lives. MEASURED: the tree n2 returns and the
57
- * tree n3 returns are the same six leaves, n4's included. Point this at any node that is up;
56
+ * Which node ANSWERS the read — not where the OSD lives. MEASURED: the tree node-b returns and the
57
+ * tree node-c returns are the same six leaves, node-d's included. Point this at any node that is up;
58
58
  * `host` is the field that says where the disk is.
59
59
  */
60
60
  node: string;
@@ -63,7 +63,7 @@ export interface CephOsdProps extends WithTarget {
63
63
  /**
64
64
  * The CRUSH host bucket this OSD must sit in. Undeclared means unmanaged, as in storage.ts.
65
65
  *
66
- * ⚠️ DECLARING IT IS AN ASSERTION WITH NO REPAIR. If osd.2 turns up under n3, the plan reports
66
+ * ⚠️ DECLARING IT IS AN ASSERTION WITH NO REPAIR. If osd.2 turns up under node-c, the plan reports
67
67
  * work every time and the deploy fails, because moving an OSD between failure domains is a
68
68
  * `ceph osd crush move` this provider will not perform. That is the intent: a replica in the
69
69
  * wrong failure domain is not something to converge quietly past.
@@ -125,7 +125,7 @@ export const ProxmoxCephOsd = Resource<ProxmoxCephOsd>('Proxmox.CephOsd', {
125
125
  * node, an expired lease, a role without `Sys.Audit`, or a cluster with no Ceph at all. Folding
126
126
  * those into "absent" would turn every one of them into the sentence "osd.2 is missing", which
127
127
  * is the most alarming thing this provider can say and would be a lie in all four cases. Left
128
- * to fail, the plan shows `PVE GET nodes/n2/ceph/osd -> 500: …` and names the real problem.
128
+ * to fail, the plan shows `PVE GET nodes/node-b/ceph/osd -> 500: …` and names the real problem.
129
129
  */
130
130
 
131
131
  /**
@@ -14,16 +14,16 @@ import { engineOver } from '../verify/fake-engine.ts';
14
14
  import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
15
15
  import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
16
16
 
17
- const STATUS = 'nodes/n2/ceph/pool/cephtb4/status?verbose=1';
18
- const OBJECT = 'nodes/n2/ceph/pool/cephtb4';
17
+ const STATUS = 'nodes/node-b/ceph/pool/rbd-c1/status?verbose=1';
18
+ const OBJECT = 'nodes/node-b/ceph/pool/rbd-c1';
19
19
 
20
- /** TB4's rbd pool as `getpool` answers it — the fields ceph-pool.ts reads. */
20
+ /** C1's rbd pool as `getpool` answers it — the fields ceph-pool.ts reads. */
21
21
  const livePool = (size: number): Record<string, unknown> => ({
22
22
  application_list: ['rbd'],
23
23
  crush_rule: 'replicated_rule',
24
24
  id: 2,
25
25
  min_size: 2,
26
- name: 'cephtb4',
26
+ name: 'rbd-c1',
27
27
  pg_autoscale_mode: 'on',
28
28
  pg_num: 128,
29
29
  size,
@@ -38,18 +38,18 @@ const cluster = (size: number) => {
38
38
  for (const [key, value] of Object.entries(call.form)) {
39
39
  pool[key] = /^\d+$/.test(value) ? Number(value) : value;
40
40
  }
41
- return 'UPID:n2:fake:setpool';
41
+ return 'UPID:node-b:fake:setpool';
42
42
  }
43
43
  return undefined;
44
44
  });
45
45
  };
46
46
 
47
47
  const declared = () =>
48
- ProxmoxCephPool('cephtb4', {
48
+ ProxmoxCephPool('rbd-c1', {
49
49
  crush_rule: 'replicated_rule',
50
50
  min_size: 2,
51
- name: 'cephtb4',
52
- node: 'n2',
51
+ name: 'rbd-c1',
52
+ node: 'node-b',
53
53
  pg_autoscale_mode: 'on',
54
54
  size: 3,
55
55
  target: FAKE_TARGET,
@@ -67,7 +67,7 @@ describe('adopting a Ceph pool', () => {
67
67
  expect(report.rows).toEqual([
68
68
  expect.objectContaining({ diff: 'noop', ok: true, planned: 'adopted', read: 'found' }),
69
69
  ]);
70
- expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
70
+ expect(await engine.deploy(declared())).toEqual({ 'rbd-c1': 'adopted' });
71
71
  });
72
72
  expect(fake.writes()).toEqual([]);
73
73
  expect(fake.calls.every((call) => call.path === STATUS)).toBe(true);
@@ -82,10 +82,10 @@ describe('adopting a Ceph pool', () => {
82
82
  test('declaring a field it does not manage: rechecked, still noop, and the deploy only reads', async () => {
83
83
  const fake = cluster(3);
84
84
  const birth = () =>
85
- ProxmoxCephPool('cephtb4', {
85
+ ProxmoxCephPool('rbd-c1', {
86
86
  min_size: 2,
87
- name: 'cephtb4',
88
- node: 'n2',
87
+ name: 'rbd-c1',
88
+ node: 'node-b',
89
89
  pg_num: 32,
90
90
  size: 3,
91
91
  target: FAKE_TARGET,
@@ -100,7 +100,7 @@ describe('adopting a Ceph pool', () => {
100
100
  recheck: 'noop',
101
101
  });
102
102
  expect(report.rows[0]?.why).toContain('does not manage');
103
- expect(await engine.deploy(birth())).toEqual({ cephtb4: 'adopted' });
103
+ expect(await engine.deploy(birth())).toEqual({ 'rbd-c1': 'adopted' });
104
104
  });
105
105
  expect(fake.writes()).toEqual([]);
106
106
  });
@@ -112,7 +112,7 @@ describe('adopting a Ceph pool', () => {
112
112
  const report = await engine.verify(declared());
113
113
  expect(report.rows[0]).toMatchObject({ changed: ['size'], diff: 'update', ok: false });
114
114
  expect(fake.writes()).toEqual([]);
115
- expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
115
+ expect(await engine.deploy(declared())).toEqual({ 'rbd-c1': 'adopted' });
116
116
  });
117
117
  expect(fake.writes()).toEqual([`PUT ${OBJECT}`]);
118
118
  expect(fake.calls.find((call) => call.method === 'PUT')?.form['size']).toBe('3');
@@ -4,25 +4,25 @@
4
4
  * ★ SPLIT OUT OF ceph-pool.ts TO KEEP BOTH FILES UNDER THE 250-LINE CAP, and the seam is
5
5
  * "does this touch the cluster" — the same one metric-server-form.ts draws, just with more on
6
6
  * this side because this family needs it. Here: the declared shape, the reported shape, and the
7
- * coercions that turn one into the other. There: reading TB4, deciding a diff, and the four
7
+ * coercions that turn one into the other. There: reading C1, deciding a diff, and the four
8
8
  * handlers. Nothing in this file makes a call or decides an action.
9
9
  * ⚠️ THE PROPS LIVE HERE RATHER THAN IN THE RESOURCE FILE, which is the one place this pair
10
10
  * differs from metric-server / notification-target. There is no `import type` cycle back as a
11
11
  * result; ceph-pool.ts re-exports both interfaces so the public surface is unchanged.
12
12
  *
13
13
  * ⛔ POST AND PUT DO NOT TAKE THE SAME PARAMETERS, AND BOTH EXTRAS ARE DESTRUCTIVE. MEASURED from
14
- * the cluster's own schema (/usr/share/pve-docs/api-viewer/apidoc.js on n2, pve-manager 9.2.11,
14
+ * the cluster's own schema (/usr/share/pve-docs/api-viewer/apidoc.js on node-b, pve-manager 9.2.11,
15
15
  * 2026-09-13): POST adds `add_storages` and `erasure-coding` to the shared set, PUT takes
16
16
  * neither. Both are refused below, so what is left between the two bodies is `name`, which PUT
17
17
  * reads from the path, and `pg_num`.
18
18
  *
19
19
  * ⛔ `pg_num` IS IN THE CREATE BODY AND NOT IN THE UPDATE BODY, AND THAT IS THE MOST IMPORTANT
20
- * LINE IN THIS FILE. MEASURED in PVE::API2::Ceph::Pool::createpool on n2: a POST carrying no
20
+ * LINE IN THIS FILE. MEASURED in PVE::API2::Ceph::Pool::createpool on node-b: a POST carrying no
21
21
  * `pg_num` gets 128 substituted (`$param->{pg_num} //= 128`), and PVE::Ceph::Tools::create_pool
22
22
  * then runs `osd pool create` — which Ceph answers with SUCCESS for a pool that already exists,
23
23
  * before applying the rest of the body to it. So a create fired at a live pool SETS its pg_num,
24
24
  * and lowering pg_num starts a PG MERGE: hours of backfill across every OSD, on a cluster whose
25
- * Ceph traffic shares vmbr1.11 with everything else. ceph-pool.ts guards the create for exactly
25
+ * Ceph traffic shares vmbr1.42 with everything else. ceph-pool.ts guards the create for exactly
26
26
  * that reason; this half simply never offers pg_num to an update.
27
27
  */
28
28
  import type { WithTarget } from './resource.ts';
@@ -30,10 +30,10 @@ import { csv } from './values.ts';
30
30
 
31
31
  export interface CephPoolProps extends WithTarget {
32
32
  /**
33
- * ⚠️ WHICH NODE THE CALL GOES THROUGH, NOT WHERE THE POOL LIVES. A pool is cluster-wide: n2 and
34
- * n3 answer byte-identically for `cephtb4` (measured). The endpoint is node-scoped only
33
+ * ⚠️ WHICH NODE THE CALL GOES THROUGH, NOT WHERE THE POOL LIVES. A pool is cluster-wide: node-b and
34
+ * node-c answer byte-identically for `rbd-c1` (measured). The endpoint is node-scoped only
35
35
  * because PVE talks to the local RADOS socket, so this is a door, not a location — it is never
36
- * compared, and moving a declaration from n2 to n3 plans noop. Point it at a node you expect
36
+ * compared, and moving a declaration from node-b to node-c plans noop. Point it at a node you expect
37
37
  * to be up: a node that is down makes the read fail, and a failed read reads as "absent".
38
38
  */
39
39
  node: string;
@@ -51,7 +51,7 @@ export interface CephPoolProps extends WithTarget {
51
51
  min_size?: number;
52
52
  /**
53
53
  * ⛔ CREATE-TIME ONLY, AND NEVER COMPARED — the single biggest noop hazard in this family. With
54
- * `pg_autoscale_mode` on or warn the autoscaler owns this number: MEASURED on TB4, `cephtb4`
54
+ * `pg_autoscale_mode` on or warn the autoscaler owns this number: MEASURED on C1, `rbd-c1`
55
55
  * sits at pg_num 128 while the autoscaler's own `pg_num_final` is 256, so a declaration
56
56
  * diffing it reports an update the moment Ceph decides to act. A split is gradual even with
57
57
  * the autoscaler off, and `nopgchange` can refuse the write outright — which kills the WHOLE
@@ -61,7 +61,7 @@ export interface CephPoolProps extends WithTarget {
61
61
  pg_num?: number;
62
62
  /** The floor the autoscaler may not go below. Operator-owned and stable, so it IS compared. */
63
63
  pg_num_min?: number;
64
- /** ⚠️ PVE's create default is `warn`; Ceph's own is `on`, which is what all four TB4 pools have. */
64
+ /** ⚠️ PVE's create default is `warn`; Ceph's own is `on`, which is what all four C1 pools have. */
65
65
  pg_autoscale_mode?: 'off' | 'on' | 'warn';
66
66
  /**
67
67
  * The CRUSH rule BY NAME, e.g. `replicated_rule`.
@@ -108,7 +108,7 @@ export interface CephPoolProps extends WithTarget {
108
108
  * size or its PG shape, and `set_pool` dies on the whole PUT when any one parameter will not
109
109
  * apply (Tools.pm:310). `matches` refuses to diff the fields they freeze, so these attributes
110
110
  * are the only place a plan can show WHY a declared size is not being enforced. All three read
111
- * false on all four TB4 pools.
111
+ * false on all four C1 pools.
112
112
  * ⚠️ `pg_num`, `id` AND `applications` ARE REPORTED AND NEVER COMPARED — see the props above.
113
113
  * ⛔ NO `statistics` AND NO `autoscale_status`, though the verbose read returns both. They change
114
114
  * every few seconds; persisting them would rewrite this resource's state on every deploy and
@@ -170,7 +170,7 @@ export const same = <T>(declared: T | undefined, live: T, frozen = false) =>
170
170
  * A declared autoscaler hint, or nothing when it is zero.
171
171
  *
172
172
  * ⚠️ A DECLARED ZERO IS "NO FLOOR, NO HINT", AND IT IS DELIBERATELY LEFT UNCOMPARED. REASONED, NOT
173
- * MEASURED: all four TB4 pools carry null for both `pg_num_min` and `target_size`, and finding
173
+ * MEASURED: all four C1 pools carry null for both `pg_num_min` and `target_size`, and finding
174
174
  * out for certain would mean writing to the cluster. Ceph CLEARS both when they are set to 0 and
175
175
  * `osd pool get all` then omits the key, which PVE reports as null and this pair reads as UNSET
176
176
  * — so a declared 0 would compare 0 against -1 on every plan, forever. Mapping 0 to UNSET
@@ -203,7 +203,7 @@ const numeric = (value: number | undefined) => (value === undefined ? undefined
203
203
  * ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED — storage.ts's rule, and the opposite of
204
204
  * Proxmox.Pool's. Undeclared means UNMANAGED here, because a pool has no one set of defaults to
205
205
  * fall back on: PVE's POST fills in size 3, min_size 2, application rbd and pg_autoscale_mode
206
- * 'warn' (MEASURED in createpool), while Ceph's own defaults — which is what every pool on TB4
206
+ * 'warn' (MEASURED in createpool), while Ceph's own defaults — which is what every pool on C1
207
207
  * was actually born with — put pg_autoscale_mode at 'on'. Guessing either set would rewrite a
208
208
  * pool somebody tuned by hand.
209
209
  *
@@ -216,7 +216,7 @@ const numeric = (value: number | undefined) => (value === undefined ? undefined
216
216
  * NOT PVE's `1T` STRING. MEASURED: the API parses the string with PVE::JSONSchema::parse_size
217
217
  * and stores Ceph's `target_size_bytes` (Pool.pm:474-477 for POST, 683-686 for PUT), and
218
218
  * `GET .../status` hands that integer back under the name `target_size` (Pool.pm:878). Run on
219
- * n2, `parse_size('1099511627776')` returns 1099511627776 — bare digits are bytes, no
219
+ * node-b, `parse_size('1099511627776')` returns 1099511627776 — bare digits are bytes, no
220
220
  * multiplier — so a byte count survives the round trip exactly, while a declared `1T` would read
221
221
  * back as 1099511627776 and diff against itself on every plan, forever.
222
222
  */
@@ -18,10 +18,10 @@
18
18
  * string, so HTTP 200 means "the task started" — an immediate read-back can see a pool that does
19
19
  * not exist yet, or values that have not landed. `settle` waits for the cluster to agree.
20
20
  *
21
- * ⛔ THE FOUR POOLS ON TB4 INCLUDE CEPH'S OWN, AND NONE OF THEM MAY BE ADOPTED. Measured
21
+ * ⛔ THE FOUR POOLS ON C1 INCLUDE CEPH'S OWN, AND NONE OF THEM MAY BE ADOPTED. Measured
22
22
  * 2026-09-13: `.mgr` (application `mgr`, created and owned by the manager daemons),
23
- * `cephfs-tb4_data` and `cephfs-tb4_metadata` (the two halves of the `cephfs-tb4` filesystem —
24
- * destroying either destroys the filesystem), and `cephtb4`, the rbd pool the guests sit on.
23
+ * `cephfs-c1_data` and `cephfs-c1_metadata` (the two halves of the `cephfs-c1` filesystem —
24
+ * destroying either destroys the filesystem), and `rbd-c1`, the rbd pool the guests sit on.
25
25
  * `list` answers empty like every resource in this package, so adoption stays an explicit act;
26
26
  * the `applications` attribute is reported so that a plan SHOWS which of those a declaration hit.
27
27
  *
@@ -107,9 +107,9 @@ const spec: PveSpec<CephPoolProps, CephPoolAttributes> = {
107
107
  * write only ever ADDS to a list), `target_size_ratio` (float equality), `id`, `applications`
108
108
  * and the three `no*` flags (PVE returns them and accepts none of them on write), `node` and
109
109
  * `name` (the address the read was made at — true by construction, never a diff). In, and each
110
- * measured to round-trip unchanged against TB4's four pools on 2026-09-13: size 3, min_size 2,
110
+ * measured to round-trip unchanged against C1's four pools on 2026-09-13: size 3, min_size 2,
111
111
  * pg_autoscale_mode `on`, crush_rule `replicated_rule`, pg_num_min where it is set (16 on
112
- * cephfs-tb4_metadata, absent on cephtb4), target_size in bytes — and each of the last two
112
+ * cephfs-c1_metadata, absent on rbd-c1), target_size in bytes — and each of the last two
113
113
  * through `hint`, which drops a declared zero for the reason given on it.
114
114
  */
115
115
  matches: (attributes, props) =>
@@ -197,7 +197,7 @@ export const ProxmoxCephPoolProvider = () =>
197
197
  * to remove deliberately.
198
198
  * ⚠️ AND THE REFUSAL CAN ARRIVE AFTER THE RESPONSE, WHICH IS WHY THIS WAITS. The DELETE
199
199
  * forks a worker and answers 200 with a UPID, so mons running with
200
- * `mon_allow_pool_delete` false fail once the call has already returned. MEASURED on TB4
200
+ * `mon_allow_pool_delete` false fail once the call has already returned. MEASURED on C1
201
201
  * 2026-09-13: the config-db value is false while the running mons report true, i.e. it is
202
202
  * set in ceph.conf and the two sources disagree by design.
203
203
  */
@@ -25,7 +25,7 @@ const CRED: PveCredential = {
25
25
 
26
26
  const target = (members: readonly string[]): PveTarget => ({
27
27
  members,
28
- mount: 'proxmox-tb4-test',
28
+ mount: 'proxmox-c1-test',
29
29
  scheme: 'pve',
30
30
  });
31
31
 
@@ -148,7 +148,7 @@ describe('cluster member failover', () => {
148
148
  const backup = counting('UPID:1');
149
149
  try {
150
150
  await run(
151
- pveWith(target([A, B]), CRED, 'PUT', 'nodes/n3/network').pipe(
151
+ pveWith(target([A, B]), CRED, 'PUT', 'nodes/node-c/network').pipe(
152
152
  Effect.timeout('50 millis'),
153
153
  Effect.flip,
154
154
  ),
@@ -169,7 +169,7 @@ describe('cluster member failover', () => {
169
169
  const backup = counting('UPID:2');
170
170
  try {
171
171
  // Loopback port 1 is closed: Bun answers ConnectionRefused before a byte is written.
172
- const data = await run(pveWith(target([A, B]), CRED, 'PUT', 'nodes/n3/network'), {
172
+ const data = await run(pveWith(target([A, B]), CRED, 'PUT', 'nodes/node-c/network'), {
173
173
  [A]: 1,
174
174
  [B]: backup.port,
175
175
  });
@@ -4,7 +4,7 @@
4
4
  * ★ PLAIN `fetch`, NO TLS ESCAPE HATCH, AND THAT IS A MEASURED CHOICE RATHER THAN AN OMISSION.
5
5
  * Proxmox is usually met with `curl -k` because a fresh install serves its own self-signed
6
6
  * certificate — I reached for `-k` myself before checking. This cluster serves a real Let's
7
- * Encrypt certificate (`CN=cluster-tb4.example.com`), and strict TLS answers 401, i.e. the
7
+ * Encrypt certificate (`CN=cluster-c1.example.com`), and strict TLS answers 401, i.e. the
8
8
  * handshake succeeds and only the credential is missing. So there is no `rejectUnauthorized`
9
9
  * option here to be left switched on by accident in somebody else's estate.
10
10
  *
@@ -93,7 +93,7 @@ const buildRequest = (
93
93
  * package can perform was broken from the day the client was written.
94
94
  * ⛔ NOTHING CAUGHT IT BECAUSE NOTHING HAD EVER WRITTEN. Ninety-eight resources were adopted
95
95
  * against the live cluster and every one matched, so `reconcile` never reached a PUT. The
96
- * identical bug in house/forgejo/src/client.ts surfaced the moment a write token existed
96
+ * identical bug in <estate>/forgejo/src/client.ts surfaced the moment a write token existed
97
97
  * and answered `422 Unsupported Content-Type`; this one is the same line, found by looking.
98
98
  * ★ A PLAN THAT SAYS `noop` PROVES THE READ PATH AND NOTHING ELSE. That is the lesson worth
99
99
  * more than the fix.
@@ -213,7 +213,7 @@ export const pveEnvelopeWith = (
213
213
  * Make one call on a credential for `role`, REUSING a still-valid lease rather than minting one.
214
214
  *
215
215
  * 🔴 IT USED TO MINT PER CALL, AND AT 98 RESOURCES THAT STOPPED BEING FREE. One plan plus deploy
216
- * of the TB4 stack left 760 token entries in `/etc/pve/user.cfg` — read, diff, reconcile and the
216
+ * of the C1 stack left 760 token entries in `/etc/pve/user.cfg` — read, diff, reconcile and the
217
217
  * read-back each minted their own, and `/etc/pve` is a replicated cluster filesystem every node
218
218
  * has to agree on. src/lease-cache.ts carries the measurement and the safety argument.
219
219
  *
@@ -0,0 +1,148 @@
1
+ /**
2
+ * A literal credential in a plain `Pbs.NotificationTarget` prop is refused — and refused BEFORE
3
+ * Alchemy writes the props to the state store, which is the whole point.
4
+ *
5
+ * ★ THE END-TO-END CASES RUN ALCHEMY'S OWN PLAN AND APPLY (verify/fake-engine.ts) and dump the
6
+ * store, because the failure being pinned is not "a refusal exists" but "the token never
7
+ * reached the row": before the fix a refused create had already committed its props.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import * as Layer from 'effect/Layer';
11
+ import { engineOver } from '../verify/fake-engine.ts';
12
+ import type { PbsTarget } from './credentials.ts';
13
+ import {
14
+ isCredentialName,
15
+ literalCredentialHeaders,
16
+ literalCredentialsInUrl,
17
+ } from './credential-literals.ts';
18
+ import { fakeNotify } from './fake-pbs-notify.ts';
19
+ import { withoutBao } from './fake-pve.ts';
20
+ import {
21
+ PbsNotificationTarget,
22
+ type PbsNotificationTargetProps,
23
+ PbsNotificationTargetProvider,
24
+ } from './pbs-notification-target.ts';
25
+ import { refusals } from './pbs-notification-target-form.ts';
26
+
27
+ const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
28
+ const LITERAL = 'lit-7a1c-not-real';
29
+ const AM = 'https://alertmanager.example.com/api/v2/alerts';
30
+
31
+ const hook = (over: Partial<PbsNotificationTargetProps> = {}): PbsNotificationTargetProps => ({
32
+ method: 'post',
33
+ name: 'alertmanager',
34
+ target: PBS,
35
+ type: 'webhook',
36
+ url: AM,
37
+ ...over,
38
+ });
39
+
40
+ describe('which names are credentials', () => {
41
+ test('authorization, cookie and credential words — whole names or dash/underscore words', () => {
42
+ for (const name of ['Authorization', 'proxy-authorization', 'Cookie', 'X-Api-Key', 'api_key']) {
43
+ expect(isCredentialName(name)).toBe(true);
44
+ }
45
+ for (const name of ['X-Auth-Token', 'Private-Token', 'X-Gotify-Key', 'token', 'sig', 'code']) {
46
+ expect(isCredentialName(name)).toBe(true);
47
+ }
48
+ for (const name of ['Content-Type', 'X-Scope-OrgID', 'Author', 'Keyword', 'Passthrough']) {
49
+ expect(isCredentialName(name)).toBe(false);
50
+ }
51
+ });
52
+ });
53
+
54
+ describe('literal credentials are found by where they sit', () => {
55
+ test('headers: a literal is refused; a secrets template, `{ fromEnv }` and `""` are not', () => {
56
+ expect(
57
+ literalCredentialHeaders({
58
+ 'Content-Type': 'application/json',
59
+ Cookie: `session=${LITERAL}`,
60
+ 'X-Api-Key': { fromEnv: 'HOOK_KEY' },
61
+ Authorization: `Bearer ${LITERAL}`,
62
+ 'X-Auth-Token': '{{secrets.token}}',
63
+ 'X-Empty-Token': '',
64
+ }),
65
+ ).toEqual(['Authorization', 'Cookie']);
66
+ expect(literalCredentialHeaders({ Authorization: 'Bearer {{ secrets.token }}' })).toEqual([]);
67
+ expect(literalCredentialHeaders(undefined)).toEqual([]);
68
+ });
69
+
70
+ test('a URL: a userinfo password and credential-named query parameters, by name', () => {
71
+ expect(literalCredentialsInUrl(`https://pbs:${LITERAL}@am.example.com/x`)).toEqual([
72
+ 'userinfo',
73
+ ]);
74
+ expect(literalCredentialsInUrl(`${AM}?token=${LITERAL}&team=ops&api_key=${LITERAL}`)).toEqual([
75
+ '?token',
76
+ '?api_key',
77
+ ]);
78
+ expect(literalCredentialsInUrl(`${AM}?code=${LITERAL}#token=${LITERAL}`)).toEqual([
79
+ '?code',
80
+ '?token',
81
+ ]);
82
+ expect(literalCredentialsInUrl(`${AM}#team=ops&sig=${LITERAL}`)).toEqual(['?sig']);
83
+ });
84
+
85
+ test('a URL that references its secrets, or has no password, is clean', () => {
86
+ for (const url of [
87
+ AM,
88
+ `${AM}?team=ops`,
89
+ `${AM}?token={{ url-encode secrets.token }}`,
90
+ 'https://pbs:{{ secrets.pw }}@am.example.com/',
91
+ 'https://pbs@am.example.com/',
92
+ `${AM}?token=`,
93
+ undefined,
94
+ ]) {
95
+ expect(literalCredentialsInUrl(url)).toEqual([]);
96
+ }
97
+ });
98
+
99
+ test('refusals name the header or the URL part — never the value', () => {
100
+ const reasons = refusals(
101
+ hook({ header: { Authorization: `Bearer ${LITERAL}` }, url: `${AM}?token=${LITERAL}` }),
102
+ );
103
+ expect(reasons).toHaveLength(2);
104
+ expect(reasons[0]).toStartWith("header 'Authorization' holds a literal credential");
105
+ expect(reasons[1]).toStartWith("'url' ?token holds a literal credential");
106
+ expect(reasons.join(' ')).not.toContain(LITERAL);
107
+ });
108
+ });
109
+
110
+ describe('through Alchemy: refused at plan, before anything is stored or sent', () => {
111
+ const run = async (props: PbsNotificationTargetProps) => {
112
+ const fake = fakeNotify();
113
+ let failure = '';
114
+ let stored = '';
115
+ await withoutBao(async () => {
116
+ const engine = engineOver(
117
+ PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)),
118
+ );
119
+ await engine.deploy(PbsNotificationTarget('alertmanager', props)).catch((error: unknown) => {
120
+ failure = String(error);
121
+ });
122
+ stored = engine.stored();
123
+ });
124
+ return { failure, stored, writes: fake.writes() };
125
+ };
126
+
127
+ test('a literal Authorization header on a NEW target never reaches the store', async () => {
128
+ const { failure, stored, writes } = await run(
129
+ hook({ header: { Authorization: `Bearer ${LITERAL}` } }),
130
+ );
131
+ expect(failure).toContain("header 'Authorization' holds a literal credential");
132
+ expect(stored).not.toContain(LITERAL);
133
+ expect(writes).toEqual([]);
134
+ });
135
+
136
+ test('a literal query token on a NEW target never reaches the store', async () => {
137
+ const { failure, stored, writes } = await run(hook({ url: `${AM}?token=${LITERAL}` }));
138
+ expect(failure).toContain("'url' ?token holds a literal credential");
139
+ expect(stored).not.toContain(LITERAL);
140
+ expect(writes).toEqual([]);
141
+ });
142
+
143
+ test('any other refusal on a new target also fails before the row is written', async () => {
144
+ const { failure, stored } = await run(hook({ comment: LITERAL, mailto: ['x@example.com'] }));
145
+ expect(failure).toContain("'mailto' belongs to sendmail/smtp targets, not webhook");
146
+ expect(stored).not.toContain(LITERAL);
147
+ });
148
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A credential written as a LITERAL in a plain prop — refused, because a plain prop is a value in
3
+ * the state store.
4
+ *
5
+ * ⛔ THE HOLE THIS CLOSES, MEASURED 2026-09-22 (adversarial review of PR 95). write-only.ts keeps
6
+ * every `{ fromEnv }` value out of state, and the doc WARNED that a plain header value is a prop.
7
+ * A warning is not a guard: `header: { Authorization: 'Bearer <token>' }` planned, deployed, and
8
+ * left the token in the dumped state store — and so did `url: 'https://…?token=<token>'`. Alchemy
9
+ * stores props unencrypted (write-only.ts has the StateEncoding reading), so the only safe
10
+ * literal is one that is not a credential.
11
+ *
12
+ * ★ WHAT COUNTS AS A CREDENTIAL IS A NAME, NOT A VALUE. A value cannot be recognised — a token is
13
+ * any string — but where it sits can: an `Authorization`/`Cookie` header, a header or query
14
+ * parameter whose name has a word like `token`, `key`, `secret`, `password` or `signature`, and a
15
+ * password in the URL's userinfo. A false positive costs one edit (declare it `{ fromEnv }`, or
16
+ * hold it in `secret` and write `{{ secrets.<name> }}`); a false negative costs a leaked token.
17
+ * ★ A TEMPLATE THAT READS A SECRET IS NOT A LITERAL. `Bearer {{ secrets.token }}`, and
18
+ * `{{ url-encode secrets.token }}` in a query, render on PBS from the root-only secret store —
19
+ * the prop holds the reference, not the value.
20
+ *
21
+ * ⚠️ WHAT THIS CANNOT SEE: a token in a URL PATH (a Slack-style `/services/T…/B…/<token>`), in the
22
+ * `body`, or in a `comment`. Those are plain props too; put the token in `secret` and reference
23
+ * it from the template. docs/pbs-notifications.md says the same to the reader.
24
+ * ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
25
+ */
26
+ import type { FromEnv } from './write-only.ts';
27
+
28
+ /** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
29
+ const READS_SECRET = /\{\{[^}]*\bsecrets\./;
30
+
31
+ /** A whole-name match, or one dash/underscore-separated word of the name. */
32
+ const CREDENTIAL_WORD =
33
+ /(^|[-_])(api[-_]?key|apikey|key|token|secret|password|passwd|pwd|pass|auth|signature|sig|code)($|[-_])/i;
34
+ const CREDENTIAL_HEADER = /^(proxy-authorization|authorization|cookie)$/i;
35
+
36
+ export const isCredentialName = (name: string): boolean =>
37
+ CREDENTIAL_HEADER.test(name) || CREDENTIAL_WORD.test(name);
38
+
39
+ const literal = (value: string): boolean => value !== '' && !READS_SECRET.test(value);
40
+
41
+ /** Plain header values that are credentials by their header's name. `{ fromEnv }` is never one. */
42
+ export const literalCredentialHeaders = (
43
+ header: Readonly<Record<string, FromEnv | string>> | undefined,
44
+ ): string[] =>
45
+ Object.entries(header ?? {})
46
+ .filter(([name, value]) => typeof value === 'string' && isCredentialName(name))
47
+ .filter(([, value]) => literal(value as string))
48
+ .map(([name]) => name)
49
+ .sort();
50
+
51
+ /**
52
+ * Where a URL carries a literal credential: `userinfo` (a password before the `@`), or the names
53
+ * of credential-shaped query (or fragment) parameters. Parsed by hand, not with `new URL`: a URL here is a
54
+ * TEMPLATE, and `{{ … }}` in the host or the userinfo is not a URL `new URL` will accept.
55
+ */
56
+ export const literalCredentialsInUrl = (url: string | undefined): string[] => {
57
+ if (url === undefined) return [];
58
+ const out: string[] = [];
59
+ const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(url)?.[1] ?? '';
60
+ const at = authority.lastIndexOf('@');
61
+ if (at >= 0) {
62
+ const userinfo = authority.slice(0, at);
63
+ const colon = userinfo.indexOf(':');
64
+ if (colon >= 0 && literal(userinfo.slice(colon + 1))) out.push('userinfo');
65
+ }
66
+ // ⚠️ THE FRAGMENT TOO: it is never sent, but it is still a prop — and so still in state.
67
+ const params = url.replace(/^[^?#]*/, '');
68
+ for (const pair of params.split(/[?&#]/)) {
69
+ const eq = pair.indexOf('=');
70
+ const name = eq < 0 ? pair : pair.slice(0, eq);
71
+ if (eq >= 0 && isCredentialName(name) && literal(pair.slice(eq + 1))) out.push(`?${name}`);
72
+ }
73
+ return out;
74
+ };