@homeflare/alchemy 0.5.0 → 0.7.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 (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Renaming a Bao.Policy, through Alchemy's own plan and apply (fake-stack.ts) against a fake policy
3
+ * store keyed as OpenBao keys it (fake-engines.ts). Before 2026-09-21 the second deploy of every
4
+ * rename below planned `update` and left the old policy live, under `destroy` too.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import * as Output from 'alchemy/Output';
9
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
10
+ import * as Effect from 'effect/Effect';
11
+ import type { Fake } from './fake-bao.ts';
12
+ import { aclPolicies, fakePolicyProviders } from './fake-engines.ts';
13
+ import { type FakeStack, type StackBody, withFakeStack, writesOf as writes } from './fake-stack.ts';
14
+ import { BaoPolicy } from './policy.ts';
15
+
16
+ /** A fake policy store and a stack over it; `live` lists the policy names the store holds. */
17
+ const withStack = (body: (stack: FakeStack, bao: Fake, live: () => string[]) => Promise<void>) => {
18
+ const store = aclPolicies();
19
+ return withFakeStack(fakePolicyProviders, store, (stack, bao) =>
20
+ body(stack, bao, () =>
21
+ [...store.live.keys()].map((path) => path.replace('sys/policies/acl/', '')).sort(),
22
+ ),
23
+ );
24
+ };
25
+
26
+ type Removal = typeof RemovalPolicy.destroy;
27
+ const policy = (name: string, removal: Removal = RemovalPolicy.retain): StackBody =>
28
+ Effect.asVoid(BaoPolicy('Policy', { fragments: '/policies/app', name }).pipe(removal()));
29
+
30
+ describe('Bao.Policy rename, through the engine', () => {
31
+ it('plans replace, writes the new name first, then deletes the OLD name under destroy', async () => {
32
+ await withStack(async (stack, bao, live) => {
33
+ assert.deepEqual(await stack.deploy(policy('app-old', RemovalPolicy.destroy)), {
34
+ Policy: 'create',
35
+ });
36
+ bao.seen.length = 0;
37
+ assert.deepEqual(await stack.deploy(policy('app-new', RemovalPolicy.destroy)), {
38
+ Policy: 'replace',
39
+ });
40
+ assert.deepEqual(writes(bao.seen), [
41
+ 'PUT /v1/sys/policies/acl/app-new',
42
+ 'DELETE /v1/sys/policies/acl/app-old',
43
+ ]);
44
+ assert.deepEqual(live(), ['app-new']);
45
+ });
46
+ });
47
+
48
+ it('still plans replace under the default retain, and keeps the old policy live', async () => {
49
+ await withStack(async (stack, bao, live) => {
50
+ await stack.deploy(policy('app-old'));
51
+ assert.deepEqual(await stack.deploy(policy('app-new')), { Policy: 'replace' });
52
+ assert.ok(!writes(bao.seen).some((each) => each.startsWith('DELETE')));
53
+ assert.deepEqual(live(), ['app-new', 'app-old']);
54
+ });
55
+ });
56
+
57
+ it('treats a change of case as the same policy: noop, and nothing deleted', async () => {
58
+ await withStack(async (stack, bao, live) => {
59
+ await stack.deploy(policy('App', RemovalPolicy.destroy));
60
+ bao.seen.length = 0;
61
+ assert.deepEqual(await stack.deploy(policy('app', RemovalPolicy.destroy)), {
62
+ Policy: 'noop',
63
+ });
64
+ assert.deepEqual(writes(bao.seen), []);
65
+ assert.deepEqual(live(), ['app']);
66
+ });
67
+ });
68
+
69
+ it('plans replace while `fragments` is still an Output of a changing upstream', async () => {
70
+ const body = (dir: string, name: string): StackBody =>
71
+ Effect.gen(function* () {
72
+ const upstream = yield* BaoPolicy('Upstream', { fragments: dir, name: 'upstream' });
73
+ yield* BaoPolicy('Policy', {
74
+ fragments: Output.interpolate`/policies/${upstream.grants}`,
75
+ name,
76
+ }).pipe(RemovalPolicy.destroy());
77
+ });
78
+ await withStack(async (stack, _bao, live) => {
79
+ await stack.deploy(body('/policies/one', 'app-old'));
80
+ const planned = await stack.deploy(body('/policies/two', 'app-new'));
81
+ assert.deepEqual(planned, { Policy: 'replace', Upstream: 'update' });
82
+ assert.deepEqual(live(), ['app-new', 'upstream']);
83
+ });
84
+ });
85
+
86
+ it('refuses an update across a rename it could not see, then replaces on the next deploy', async () => {
87
+ // ★ The name is an Output of an upstream that is updating, so the diff cannot see it at plan.
88
+ const body = (dir: string): StackBody =>
89
+ Effect.gen(function* () {
90
+ const upstream = yield* BaoPolicy('Upstream', { fragments: dir, name: 'upstream' });
91
+ yield* BaoPolicy('Policy', {
92
+ fragments: '/policies/app',
93
+ name: Output.interpolate`app-${upstream.grants}`,
94
+ }).pipe(RemovalPolicy.destroy());
95
+ });
96
+ await withStack(async (stack, bao, live) => {
97
+ await stack.deploy(body('/policies/one'));
98
+ assert.deepEqual(live(), ['app-1', 'upstream']);
99
+ bao.seen.length = 0;
100
+ await assert.rejects(
101
+ stack.deploy(body('/policies/two')),
102
+ /identity moved from app-1 to app-2/,
103
+ );
104
+ assert.deepEqual(writes(bao.seen), ['PUT /v1/sys/policies/acl/upstream']);
105
+ assert.deepEqual(await stack.deploy(body('/policies/two')), {
106
+ Policy: 'replace',
107
+ Upstream: 'noop',
108
+ });
109
+ assert.deepEqual(live(), ['app-2', 'upstream']);
110
+ });
111
+ });
112
+ });
@@ -1,43 +1,24 @@
1
1
  /**
2
- * Bao.Policy's calls against a fake that behaves like OpenBao where it matters: it STRIPS the
3
- * policy's trailing newline on write (measured on the live server — see digest.ts), answers 404 for
4
- * a policy it does not have, and 204 for a delete whether or not the policy existed.
2
+ * Bao.Policy's calls against a fake that behaves like OpenBao where it matters (fake-engines.ts): it
3
+ * STRIPS the policy's trailing newline on write (measured on the live server — see digest.ts),
4
+ * answers 404 for a policy it does not have, and 204 for a delete whether or not it existed.
5
5
  */
6
6
  import assert from 'node:assert/strict';
7
7
  import { describe, it } from 'node:test';
8
8
  import { BaoError } from './bao-status.ts';
9
9
  import { sha256 } from './digest.ts';
10
- import { type Seen, run, runFailure, withFake } from './fake-bao.ts';
10
+ import { run, runFailure, withFake } from './fake-bao.ts';
11
+ import { aclPolicies } from './fake-engines.ts';
11
12
  import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
12
13
 
13
14
  const PREFIX = '/v1/sys/policies/acl/';
14
15
 
15
- const policyStore = () => {
16
- const policies = new Map<string, string>();
17
- return (seen: Seen) => {
18
- const name = seen.path.slice(PREFIX.length);
19
- if (seen.method === 'PUT') {
20
- const { policy } = JSON.parse(seen.body) as { policy: string };
21
- policies.set(name, policy.replace(/\n+$/, ''));
22
- return { status: 204 };
23
- }
24
- if (seen.method === 'DELETE') {
25
- policies.delete(name);
26
- return { status: 204 };
27
- }
28
- const policy = policies.get(name);
29
- return policy === undefined
30
- ? { json: { errors: [] }, status: 404 }
31
- : { json: { data: { name, policy } }, status: 200 };
32
- };
33
- };
34
-
35
16
  /** Two fragments, each followed by a newline — exactly how policy.ts assembles them. */
36
17
  const ASSEMBLED = 'path "kv/data/a" {\n capabilities = ["read"]\n}\n' + 'path "kv/data/b" {}\n';
37
18
 
38
19
  describe('policy wire', () => {
39
20
  it('reads a policy that was never written as absent', async () => {
40
- await withFake(policyStore(), async (bao) => {
21
+ await withFake(aclPolicies(), async (bao) => {
41
22
  assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), '');
42
23
  });
43
24
  });
@@ -45,7 +26,7 @@ describe('policy wire', () => {
45
26
  // ⛔ THE FOREVER-DIFF. Live text comes back one newline short; the digests must still agree, or
46
27
  // every plan says `update` and reconcile rewrites a policy that already matches.
47
28
  it('round-trips through the stripped newline with equal digests', async () => {
48
- await withFake(policyStore(), async (bao) => {
29
+ await withFake(aclPolicies(), async (bao) => {
49
30
  const env = { BAO_ADDR: bao.address };
50
31
  await run(env, writePolicy('homeflare-llm', ASSEMBLED));
51
32
  assert.equal(bao.seen[0]?.method, 'PUT');
@@ -7,7 +7,8 @@ import * as Path from 'effect/Path';
7
7
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
8
8
  import { sha256 } from './digest.ts';
9
9
  import { isEmptyAssembly } from './policy-assembly.ts';
10
- import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
10
+ import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
11
+ import { declaredString, isMoved, judgeMove, policyKey, refuseMovedUpdate } from './rename.ts';
11
12
 
12
13
  /**
13
14
  * An OpenBao ACL policy, assembled from the HCL fragments that declare it.
@@ -91,6 +92,8 @@ export interface BaoPolicy extends Resource<
91
92
  * orphaned resource; a caller that means it opts in with `.pipe(RemovalPolicy.destroy())`. This
92
93
  * is Terraform's `prevent_destroy`, not a stubbed operation — a delete that silently did nothing
93
94
  * would lie to whoever read the plan.
95
+ * ⚠️ `retain` ALSO KEEPS THE OLD GENERATION OF A RENAME. A new `name` plans `replace`, and under
96
+ * `retain` the old policy is left live and unmanaged (rename.ts, REPLACE.md).
94
97
  */
95
98
  export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
96
99
  defaultRemovalPolicy: 'retain',
@@ -141,11 +144,27 @@ export const BaoPolicyProvider = () =>
141
144
  * OpenBao UI is exactly the drift the check-* gates exist to catch, and a
142
145
  * provider that trusted its own state would report `noop` straight through it.
143
146
  */
144
- diff: Effect.fn(function* ({ news, output }) {
147
+ diff: Effect.fn(function* ({ news, olds, output }) {
148
+ /**
149
+ * ⛔ A RENAMED POLICY IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
150
+ * 2026-09-21 this read the new name, found nothing and planned `update`: the new policy
151
+ * was written and the old one kept every grant under no state record. Names compare as
152
+ * OpenBao keys them (`policyKey`), so a change of case is the same policy, never a
153
+ * replace. ⛔ A rename onto a policy that already exists fails the plan (`judgeMove`).
154
+ * ⚠️ UNDER THE DEFAULT `retain` THE OLD POLICY STAYS LIVE, GRANTS AND ALL, for every token,
155
+ * role and group that still names it. Under `destroy` they lose those grants at the
156
+ * delete, so a role outside this graph that names the old policy must move in the same
157
+ * PR (REPLACE.md).
158
+ */
159
+ const tried = output?.name ?? declaredString(olds, 'name');
160
+ const declared = declaredString(news, 'name');
161
+ const move = yield* judgeMove('Bao.Policy', tried, declared, policyPath, policyKey);
162
+ if (output === undefined) return undefined;
163
+ if (move !== undefined) return { action: 'replace' } as const;
145
164
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
146
165
  // own providers guard with isResolved and skip rather than guess; a diff that
147
166
  // read a Config as a string would compare a placeholder to real HCL.
148
- if (output === undefined || !isResolved(news)) return undefined;
167
+ if (!isResolved(news)) return undefined;
149
168
  const { joined } = yield* assemble(news.fragments);
150
169
  const live = yield* readPolicy(news.name);
151
170
  return live.length > 0 && sha256(live) === sha256(joined)
@@ -153,8 +172,12 @@ export const BaoPolicyProvider = () =>
153
172
  : ({ action: 'update' } as const);
154
173
  }),
155
174
 
156
- reconcile: Effect.fn(function* ({ news }) {
175
+ reconcile: Effect.fn(function* ({ news, output }) {
157
176
  const name = news.name;
177
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
178
+ if (output !== undefined && isMoved(output.name, name, policyKey) === true) {
179
+ return yield* refuseMovedUpdate(`Bao.Policy ${name}`, output.name, name);
180
+ }
158
181
  const { joined, parts } = yield* assemble(news.fragments);
159
182
  /**
160
183
  * ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
@@ -109,7 +109,7 @@ const ttlText = (seconds: number | undefined, fallback = '0') => {
109
109
  export const rolePath = (mount: string, name: string) => `${mountPath(mount)}/roles/${name}`;
110
110
 
111
111
  export const attributesOf = (
112
- props: BaoProxmoxRoleProps,
112
+ props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>,
113
113
  live: Record<string, unknown>,
114
114
  ): BaoProxmoxRoleAttributes => {
115
115
  const attrs = {
@@ -129,13 +129,13 @@ export const attributesOf = (
129
129
  * The body for `PUT <mount>/roles/<name>` — all strings, the `k=v` pairs `bao write` sent — or
130
130
  * the prop names whose duration would not parse.
131
131
  *
132
- * ⛔ A WRITE TO roles/{name} REPLACES THE ROLE, IT DOES NOT MERGE. An omitted `ttl` is written
133
- * as the field's zero value, so a resource that treated undeclared TTLs as "unmanaged" — the
134
- * Bao.Mount shape — would silently reset a lease ceiling every time it rewrote the role for an
135
- * unrelated reason. Both TTLs are therefore required props and EVERY write carries all three
136
- * fields. REASONED from the Vault secrets-framework write semantics plus this plugin's schema
137
- * marking only `mint_user` required; NOT measured, because this session was read-only and
138
- * proving it would have meant writing to the live engine.
132
+ * ⛔ EVERY WRITE CARRIES ALL THREE FIELDS, and both TTLs are required props, so `matches` covers
133
+ * the whole role and no field is left unmanaged behind a green plan.
134
+ * ⚠️ CORRECTED 2026-09-21 FROM THE PLUGIN SOURCE. This said a write REPLACES the role and resets an
135
+ * omitted TTL to zero. The port in homeflare-openbao-plugins (secrets/proxmox/path_roles.go,
136
+ * pathRolesWrite, commit 0e79154) MERGES: it loads the stored role and sets only the fields
137
+ * present (`d.GetOk`). An omitted TTL would keep its old value, which is still a value this
138
+ * resource would not be managing. The build running live was not re-checked against that source.
139
139
  *
140
140
  * ⚠️ Seconds go on the wire as integers, not as `1h`. The schema declares both TTLs
141
141
  * `"format": "seconds"`, and an integer is accepted unambiguously by every path that reads
@@ -167,6 +167,25 @@ const sameTtl = (want: string, have: string) => {
167
167
  return wantSeconds !== undefined && wantSeconds === haveSeconds;
168
168
  };
169
169
 
170
+ /**
171
+ * Why moving the role's mint user from `before` to the declared one must be refused, or undefined.
172
+ * The ⛔ on reconcile in proxmox-role.ts says why a re-scope needs `allowMintUserChange`.
173
+ *
174
+ * ★ ONE DEFINITION FOR BOTH PLACES THAT ASK. Reconcile asks it of the live role at the same path.
175
+ * The diff asks it across a rename, where `before` is the role being replaced: a rename writes a
176
+ * brand-new role, so reconcile alone would see nothing to re-scope, and a rename would become the
177
+ * way around the guard.
178
+ */
179
+ export const rescopeRefusal = (
180
+ before: string,
181
+ mintUser: string,
182
+ allowMintUserChange: string | undefined,
183
+ ): string | undefined =>
184
+ before === mintUser || allowMintUserChange === before
185
+ ? undefined
186
+ : `re-scopes mint_user ${before} → ${mintUser}. Every token this role mints inherits ` +
187
+ `the ACL of that PVE user. To re-scope deliberately, declare allowMintUserChange: '${before}'.`;
188
+
170
189
  /** True when the live role already matches the declaration on every field the plugin stores. */
171
190
  export const matches = (attributes: BaoProxmoxRoleAttributes, props: BaoProxmoxRoleProps) =>
172
191
  attributes.mintUser === props.mintUser &&
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The read Bao.ProxmoxRole makes of `<mount>/roles/<name>`, split from proxmox-role.ts so the diff,
3
+ * reconcile and the rename's re-scope check share one definition of "the live role".
4
+ */
5
+ import * as Effect from 'effect/Effect';
6
+ import { baoRead } from './bao-http.ts';
7
+ import { type BaoProxmoxRoleProps, attributesOf, rolePath } from './proxmox-role-form.ts';
8
+
9
+ /**
10
+ * The live role, or undefined when OpenBao answers 404.
11
+ *
12
+ * ⚠️ A missing MOUNT and a missing ROLE both read as undefined here, because both answer 404:
13
+ * an absent role is a read that found nothing, and a path under no mount is OpenBao's
14
+ * unsupported-path error, which is also a 404 (sdk/logical/response_util.go). They are not the
15
+ * same failure — one is a typo in `mount`, the other is a role that has yet to be created — so
16
+ * reconcile lets OpenBao's own `errors` through on the write rather than inventing a message.
17
+ * A bad mount says "no handler for route".
18
+ */
19
+ export const readRole = (props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>) =>
20
+ Effect.gen(function* () {
21
+ const live = yield* baoRead(rolePath(props.mount, props.name));
22
+ if (live === undefined) return undefined;
23
+ return attributesOf(props, live);
24
+ });
@@ -40,15 +40,25 @@ import { isResolved } from 'alchemy/Diff';
40
40
  import * as Provider from 'alchemy/Provider';
41
41
  import * as Effect from 'effect/Effect';
42
42
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
43
- import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
43
+ import { baoDelete, baoWrite } from './bao-http.ts';
44
44
  import {
45
45
  type BaoProxmoxRoleAttributes,
46
46
  type BaoProxmoxRoleProps,
47
- attributesOf,
48
47
  matches,
48
+ rescopeRefusal,
49
49
  rolePath,
50
50
  writeBody,
51
51
  } from './proxmox-role-form.ts';
52
+ import { readRole } from './proxmox-role-wire.ts';
53
+ import {
54
+ declaredRolePath,
55
+ declaredString,
56
+ isMoved,
57
+ isPendingProp,
58
+ judgeMove,
59
+ refuseMovedUpdate,
60
+ triedRolePath,
61
+ } from './rename.ts';
52
62
 
53
63
  export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps };
54
64
 
@@ -64,21 +74,6 @@ export const BaoProxmoxRole = Resource<BaoProxmoxRole>('Bao.ProxmoxRole', {
64
74
  defaultRemovalPolicy: 'retain',
65
75
  });
66
76
 
67
- /**
68
- * ⚠️ A missing MOUNT and a missing ROLE both read as undefined here, because both answer 404:
69
- * an absent role is a read that found nothing, and a path under no mount is OpenBao's
70
- * unsupported-path error, which is also a 404 (sdk/logical/response_util.go). They are not the
71
- * same failure — one is a typo in `mount`, the other is a role that has yet to be created — so
72
- * reconcile lets OpenBao's own `errors` through on the write rather than inventing a message.
73
- * A bad mount says "no handler for route".
74
- */
75
- const readRole = (props: BaoProxmoxRoleProps) =>
76
- Effect.gen(function* () {
77
- const live = yield* baoRead(rolePath(props.mount, props.name));
78
- if (live === undefined) return undefined;
79
- return attributesOf(props, live);
80
- });
81
-
82
77
  export const BaoProxmoxRoleProvider = () =>
83
78
  Provider.effect(
84
79
  BaoProxmoxRole,
@@ -103,8 +98,35 @@ export const BaoProxmoxRoleProvider = () =>
103
98
  * says everything is fine — exactly the drift the hand-written check-* gates exist to
104
99
  * catch, and exactly what a provider that trusted its own state would walk past.
105
100
  */
106
- diff: Effect.fn(function* ({ news, output }) {
107
- if (output === undefined || !isResolved(news)) return undefined;
101
+ diff: Effect.fn(function* ({ news, olds, output }) {
102
+ /**
103
+ * ⛔ A NEW `mount` OR `name` IS A `replace` (rename.ts). It is a DIFFERENT path, so none of
104
+ * the reasons below apply. Until 2026-09-21 it planned `update` and left the old role
105
+ * minting under no state record. Under the default `retain` the old role still mints;
106
+ * under `destroy` its consumers fail at the delete (REPLACE.md). ⛔ A move onto a role
107
+ * that already exists fails the plan (`judgeMove`).
108
+ * ⛔ THE RE-SCOPE GUARD TRAVELS WITH THE RENAME, checked here because the new generation's
109
+ * reconcile finds no live role to compare. While `mintUser` or `allowMintUserChange` is
110
+ * still an Output it cannot be checked, so the diff defers, and reconcile refuses the
111
+ * resulting `update`.
112
+ */
113
+ const tried = triedRolePath(output, olds, rolePath);
114
+ const declared = declaredRolePath(news, rolePath);
115
+ const move = yield* judgeMove('Bao.ProxmoxRole', tried, declared, (path) => path);
116
+ if (output === undefined) return undefined;
117
+ if (move !== undefined) {
118
+ const mintUser = declaredString(news, 'mintUser');
119
+ if (mintUser === undefined || isPendingProp(news, 'allowMintUserChange'))
120
+ return undefined;
121
+ const from = (yield* readRole(output))?.mintUser ?? output.mintUser;
122
+ const allow = declaredString(news, 'allowMintUserChange');
123
+ const refusal = rescopeRefusal(from, mintUser, allow);
124
+ if (refusal === undefined) return { action: 'replace' } as const;
125
+ return yield* Effect.die(
126
+ new Error(`Bao.ProxmoxRole ${move.from} → ${move.to}: the rename ${refusal}`),
127
+ );
128
+ }
129
+ if (!isResolved(news)) return undefined;
108
130
  const live = yield* readRole(news);
109
131
  if (live === undefined) return { action: 'update' } as const;
110
132
  if (matches(live, news)) return { action: 'noop' } as const;
@@ -126,7 +148,13 @@ export const BaoProxmoxRoleProvider = () =>
126
148
  return { action: 'update' } as const;
127
149
  }),
128
150
 
129
- reconcile: Effect.fn(function* ({ news }) {
151
+ reconcile: Effect.fn(function* ({ news, output }) {
152
+ const path = rolePath(news.mount, news.name);
153
+ // ⛔ An `update` across a move the diff could not see — refused before any read or write.
154
+ const before = output === undefined ? path : rolePath(output.mount, output.name);
155
+ if (isMoved(before, path) === true) {
156
+ return yield* refuseMovedUpdate('Bao.ProxmoxRole', before, path);
157
+ }
130
158
  const live = yield* readRole(news);
131
159
  /**
132
160
  * ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
@@ -137,20 +165,15 @@ export const BaoProxmoxRoleProvider = () =>
137
165
  * move unless the declaration NAMES THE VALUE IT IS REPLACING. Creating a role never
138
166
  * trips this — there is nothing to re-scope — and the permission expires by itself
139
167
  * once the change lands, because live then equals the declaration.
168
+ * ⚠️ A RENAME CREATES A NEW ROLE, SO IT NEVER REACHES THIS CHECK. The diff asks the same
169
+ * question of the role being replaced (`rescopeRefusal`, proxmox-role-form.ts).
140
170
  */
141
- if (
142
- live !== undefined &&
143
- live.mintUser !== news.mintUser &&
144
- news.allowMintUserChange !== live.mintUser
145
- ) {
146
- return yield* Effect.die(
147
- new Error(
148
- `Bao.ProxmoxRole ${rolePath(news.mount, news.name)}: live mint_user ` +
149
- `${live.mintUser} != declared ${news.mintUser}. Every token this role mints ` +
150
- 'inherits the ACL of that PVE user. To re-scope deliberately, declare ' +
151
- `allowMintUserChange: '${live.mintUser}'.`,
152
- ),
153
- );
171
+ const rescope =
172
+ live === undefined
173
+ ? undefined
174
+ : rescopeRefusal(live.mintUser, news.mintUser, news.allowMintUserChange);
175
+ if (rescope !== undefined) {
176
+ return yield* Effect.die(new Error(`Bao.ProxmoxRole ${path}: ${rescope}`));
154
177
  }
155
178
  if (live === undefined || !matches(live, news)) {
156
179
  const write = writeBody(news);
@@ -164,18 +187,18 @@ export const BaoProxmoxRoleProvider = () =>
164
187
  if (!write.ok) {
165
188
  return yield* Effect.die(
166
189
  new Error(
167
- `Bao.ProxmoxRole ${rolePath(news.mount, news.name)}: unparseable duration ` +
190
+ `Bao.ProxmoxRole ${path}: unparseable duration ` +
168
191
  `${write.bad.join(', ')}. Use 30s / 5m / 1h / 1d, or 0.`,
169
192
  ),
170
193
  );
171
194
  }
172
- yield* baoWrite('PUT', rolePath(news.mount, news.name), write.body);
195
+ yield* baoWrite('PUT', path, write.body);
173
196
  }
174
197
  const after = yield* readRole(news);
175
198
  if (after === undefined) {
176
199
  return yield* Effect.die(
177
200
  new Error(
178
- `Bao.ProxmoxRole ${rolePath(news.mount, news.name)}: write returned no error ` +
201
+ `Bao.ProxmoxRole ${path}: write returned no error ` +
179
202
  'but the role is still absent.',
180
203
  ),
181
204
  );
@@ -197,7 +220,9 @@ export const BaoProxmoxRoleProvider = () =>
197
220
  * So this is an availability change for every consumer of `creds/<name>` and NOT a
198
221
  * containment action: to contain a leaked credential, revoke the lease (which deletes
199
222
  * the PVE token) or delete the token in PVE. Deleting the role only hides the door it
200
- * came through. REASONED from Vault lease semantics; not exercised here.
223
+ * came through. The plugin's own test pins this (homeflare-openbao-plugins,
224
+ * TestDeletingARoleLeavesItsOutstandingLeasesRevocable): the delete is one storage
225
+ * delete, and each lease stays revocable without the role.
201
226
  *
202
227
  * ⚠️ IDEMPOTENT AS ALCHEMY REQUIRES — already gone is success. It used to get that by
203
228
  * going through `baoReadText`, which discarded the exit code entirely, so a REFUSED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * sys/remount and its status loop against a fake. The shapes pinned here are the CLI's own
3
+ * (api/sys_mounts.go:95-191): POST `{from, to}`, then poll until `success` or `failure`.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
9
+ import { RemountError, remountAndWait } from './remount-wire.ts';
10
+
11
+ const STARTED = { json: { data: { migration_id: 'm-1' }, warnings: ['queued'] }, status: 200 };
12
+ const status = (value: string): Reply => ({
13
+ json: {
14
+ data: {
15
+ migration_id: 'm-1',
16
+ migration_info: { source_mount: 'kv/', status: value, target_mount: 'kv-moved/' },
17
+ },
18
+ },
19
+ status: 200,
20
+ });
21
+ const FORGOTTEN: Reply = { json: { errors: [] }, status: 404 };
22
+
23
+ /** Answer the start, then each status read from `statuses` in order (the last one repeats). */
24
+ const script = (...statuses: Reply[]) => {
25
+ let polls = 0;
26
+ return (seen: Seen): Reply => {
27
+ if (seen.path === '/v1/sys/remount') return STARTED;
28
+ const reply = statuses[Math.min(polls, statuses.length - 1)] ?? FORGOTTEN;
29
+ polls += 1;
30
+ return reply;
31
+ };
32
+ };
33
+
34
+ const FAST = { attempts: 3, intervalMillis: 1 };
35
+ const moved = (confirm = Effect.succeed(true)) => remountAndWait('kv', 'kv-moved', confirm, FAST);
36
+
37
+ describe('remountAndWait', () => {
38
+ it('posts from/to and polls until success', async () => {
39
+ await withFake(script(status('in-progress'), status('success')), async (bao) => {
40
+ await run({ BAO_ADDR: bao.address }, moved());
41
+ assert.deepEqual(
42
+ bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
43
+ [
44
+ ['POST', '/v1/sys/remount', JSON.stringify({ from: 'kv', to: 'kv-moved' })],
45
+ ['GET', '/v1/sys/remount/status/m-1', ''],
46
+ ['GET', '/v1/sys/remount/status/m-1', ''],
47
+ ],
48
+ );
49
+ });
50
+ });
51
+
52
+ it('fails a migration OpenBao reports failed, naming the id', async () => {
53
+ await withFake(script(status('failure')), async (bao) => {
54
+ await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), (error: unknown) => {
55
+ assert.ok(error instanceof RemountError);
56
+ assert.match(error.message, /migration m-1\) FAILED/);
57
+ return true;
58
+ });
59
+ });
60
+ });
61
+
62
+ it('settles a forgotten migration from the mount tables, both ways', async () => {
63
+ await withFake(script(FORGOTTEN), async (bao) => {
64
+ await run({ BAO_ADDR: bao.address }, moved(Effect.succeed(true)));
65
+ });
66
+ await withFake(script(FORGOTTEN), async (bao) => {
67
+ await assert.rejects(
68
+ run({ BAO_ADDR: bao.address }, moved(Effect.succeed(false))),
69
+ /no longer knows the migration/,
70
+ );
71
+ });
72
+ });
73
+
74
+ it('gives up after the bounded number of status reads', async () => {
75
+ await withFake(script(status('in-progress')), async (bao) => {
76
+ await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), /still in progress after 3/);
77
+ assert.equal(bao.seen.length, 4);
78
+ });
79
+ });
80
+
81
+ it('fails a start that names no migration id', async () => {
82
+ const answer = () => ({ json: { data: {} }, status: 200 });
83
+ await withFake(answer, async (bao) => {
84
+ await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), /no migration_id/);
85
+ });
86
+ });
87
+ });