@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.
- package/README.md +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +24 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +163 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- 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
|
|
3
|
-
* policy's trailing newline on write (measured on the live server — see digest.ts),
|
|
4
|
-
* a policy it does not have, and 204 for a delete whether or not
|
|
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 {
|
|
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(
|
|
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(
|
|
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');
|
package/src/openbao/policy.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
* ⛔
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
live
|
|
143
|
-
|
|
144
|
-
|
|
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 ${
|
|
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',
|
|
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 ${
|
|
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.
|
|
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
|
+
});
|