@homeflare/alchemy 0.6.0 → 0.8.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 +74 -41
- 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 +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -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 +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -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 +51 -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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +16 -12
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.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.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.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/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -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/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -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 +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- 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 +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -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,9 @@ 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 { policyKey } from './rename.ts';
|
|
12
|
+
import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* An OpenBao ACL policy, assembled from the HCL fragments that declare it.
|
|
@@ -91,11 +93,16 @@ export interface BaoPolicy extends Resource<
|
|
|
91
93
|
* orphaned resource; a caller that means it opts in with `.pipe(RemovalPolicy.destroy())`. This
|
|
92
94
|
* is Terraform's `prevent_destroy`, not a stubbed operation — a delete that silently did nothing
|
|
93
95
|
* would lie to whoever read the plan.
|
|
96
|
+
* ⚠️ `retain` ALSO KEEPS THE OLD GENERATION OF A RENAME. A new `name` plans `replace`, and under
|
|
97
|
+
* `retain` the old policy is left live and unmanaged (rename.ts, REPLACE.md).
|
|
94
98
|
*/
|
|
95
99
|
export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
|
|
96
100
|
defaultRemovalPolicy: 'retain',
|
|
97
101
|
});
|
|
98
102
|
|
|
103
|
+
/** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
|
|
104
|
+
const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
|
|
105
|
+
|
|
99
106
|
/** `path "..."` grant count — the same number the apply script prints. */
|
|
100
107
|
const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
|
|
101
108
|
|
|
@@ -141,11 +148,25 @@ export const BaoPolicyProvider = () =>
|
|
|
141
148
|
* OpenBao UI is exactly the drift the check-* gates exist to catch, and a
|
|
142
149
|
* provider that trusted its own state would report `noop` straight through it.
|
|
143
150
|
*/
|
|
144
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
151
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
152
|
+
/**
|
|
153
|
+
* ⛔ A RENAMED POLICY IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
|
|
154
|
+
* 2026-09-21 this read the new name, found nothing and planned `update`: the new policy
|
|
155
|
+
* was written and the old one kept every grant under no state record. Names compare as
|
|
156
|
+
* OpenBao keys them (`policyKey`), so a change of case is the same policy, never a
|
|
157
|
+
* replace. ⛔ A rename onto a policy that already exists fails the plan (`judgeMove`).
|
|
158
|
+
* ⚠️ UNDER THE DEFAULT `retain` THE OLD POLICY STAYS LIVE, GRANTS AND ALL, for every token,
|
|
159
|
+
* role and group that still names it. Under `destroy` they lose those grants at the
|
|
160
|
+
* delete, so a role outside this graph that names the old policy must move in the same
|
|
161
|
+
* PR (REPLACE.md).
|
|
162
|
+
*/
|
|
163
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
164
|
+
if (output === undefined) return undefined;
|
|
165
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
145
166
|
// ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
|
|
146
167
|
// own providers guard with isResolved and skip rather than guess; a diff that
|
|
147
168
|
// read a Config as a string would compare a placeholder to real HCL.
|
|
148
|
-
if (
|
|
169
|
+
if (!isResolved(news)) return undefined;
|
|
149
170
|
const { joined } = yield* assemble(news.fragments);
|
|
150
171
|
const live = yield* readPolicy(news.name);
|
|
151
172
|
return live.length > 0 && sha256(live) === sha256(joined)
|
|
@@ -153,8 +174,10 @@ export const BaoPolicyProvider = () =>
|
|
|
153
174
|
: ({ action: 'update' } as const);
|
|
154
175
|
}),
|
|
155
176
|
|
|
156
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
177
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
157
178
|
const name = news.name;
|
|
179
|
+
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
180
|
+
yield* guardRename(IDENTITY, news, output);
|
|
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,18 @@ 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 { declaredString, isPendingProp } from './rename.ts';
|
|
54
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
52
55
|
|
|
53
56
|
export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps };
|
|
54
57
|
|
|
@@ -64,20 +67,8 @@ export const BaoProxmoxRole = Resource<BaoProxmoxRole>('Bao.ProxmoxRole', {
|
|
|
64
67
|
defaultRemovalPolicy: 'retain',
|
|
65
68
|
});
|
|
66
69
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
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
|
-
});
|
|
70
|
+
/** Exact: the plugin stores `roles/<name>` verbatim. */
|
|
71
|
+
const IDENTITY = roleIdentity<BaoProxmoxRoleAttributes>('Bao.ProxmoxRole', rolePath);
|
|
81
72
|
|
|
82
73
|
export const BaoProxmoxRoleProvider = () =>
|
|
83
74
|
Provider.effect(
|
|
@@ -103,8 +94,33 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
103
94
|
* says everything is fine — exactly the drift the hand-written check-* gates exist to
|
|
104
95
|
* catch, and exactly what a provider that trusted its own state would walk past.
|
|
105
96
|
*/
|
|
106
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
107
|
-
|
|
97
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
98
|
+
/**
|
|
99
|
+
* ⛔ A NEW `mount` OR `name` IS A `replace` (rename.ts). It is a DIFFERENT path, so none of
|
|
100
|
+
* the reasons below apply. Until 2026-09-21 it planned `update` and left the old role
|
|
101
|
+
* minting under no state record. Under the default `retain` the old role still mints;
|
|
102
|
+
* under `destroy` its consumers fail at the delete (REPLACE.md). ⛔ A move onto a role
|
|
103
|
+
* that already exists fails the plan (`judgeMove`).
|
|
104
|
+
* ⛔ THE RE-SCOPE GUARD TRAVELS WITH THE RENAME, checked here because the new generation's
|
|
105
|
+
* reconcile finds no live role to compare. While `mintUser` or `allowMintUserChange` is
|
|
106
|
+
* still an Output it cannot be checked, so the diff defers, and reconcile refuses the
|
|
107
|
+
* resulting `update`.
|
|
108
|
+
*/
|
|
109
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
110
|
+
if (output === undefined) return undefined;
|
|
111
|
+
if (move !== undefined) {
|
|
112
|
+
const mintUser = declaredString(news, 'mintUser');
|
|
113
|
+
if (mintUser === undefined || isPendingProp(news, 'allowMintUserChange'))
|
|
114
|
+
return undefined;
|
|
115
|
+
const from = (yield* readRole(output))?.mintUser ?? output.mintUser;
|
|
116
|
+
const allow = declaredString(news, 'allowMintUserChange');
|
|
117
|
+
const refusal = rescopeRefusal(from, mintUser, allow);
|
|
118
|
+
if (refusal === undefined) return { action: 'replace' } as const;
|
|
119
|
+
return yield* Effect.die(
|
|
120
|
+
new Error(`Bao.ProxmoxRole ${move.from} → ${move.to}: the rename ${refusal}`),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!isResolved(news)) return undefined;
|
|
108
124
|
const live = yield* readRole(news);
|
|
109
125
|
if (live === undefined) return { action: 'update' } as const;
|
|
110
126
|
if (matches(live, news)) return { action: 'noop' } as const;
|
|
@@ -126,7 +142,10 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
126
142
|
return { action: 'update' } as const;
|
|
127
143
|
}),
|
|
128
144
|
|
|
129
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
145
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
146
|
+
const path = rolePath(news.mount, news.name);
|
|
147
|
+
// ⛔ An `update` across a move the diff could not see — refused before any read or write.
|
|
148
|
+
yield* guardRename(IDENTITY, news, output);
|
|
130
149
|
const live = yield* readRole(news);
|
|
131
150
|
/**
|
|
132
151
|
* ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
|
|
@@ -137,20 +156,15 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
137
156
|
* move unless the declaration NAMES THE VALUE IT IS REPLACING. Creating a role never
|
|
138
157
|
* trips this — there is nothing to re-scope — and the permission expires by itself
|
|
139
158
|
* once the change lands, because live then equals the declaration.
|
|
159
|
+
* ⚠️ A RENAME CREATES A NEW ROLE, SO IT NEVER REACHES THIS CHECK. The diff asks the same
|
|
160
|
+
* question of the role being replaced (`rescopeRefusal`, proxmox-role-form.ts).
|
|
140
161
|
*/
|
|
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
|
-
);
|
|
162
|
+
const rescope =
|
|
163
|
+
live === undefined
|
|
164
|
+
? undefined
|
|
165
|
+
: rescopeRefusal(live.mintUser, news.mintUser, news.allowMintUserChange);
|
|
166
|
+
if (rescope !== undefined) {
|
|
167
|
+
return yield* Effect.die(new Error(`Bao.ProxmoxRole ${path}: ${rescope}`));
|
|
154
168
|
}
|
|
155
169
|
if (live === undefined || !matches(live, news)) {
|
|
156
170
|
const write = writeBody(news);
|
|
@@ -164,18 +178,18 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
164
178
|
if (!write.ok) {
|
|
165
179
|
return yield* Effect.die(
|
|
166
180
|
new Error(
|
|
167
|
-
`Bao.ProxmoxRole ${
|
|
181
|
+
`Bao.ProxmoxRole ${path}: unparseable duration ` +
|
|
168
182
|
`${write.bad.join(', ')}. Use 30s / 5m / 1h / 1d, or 0.`,
|
|
169
183
|
),
|
|
170
184
|
);
|
|
171
185
|
}
|
|
172
|
-
yield* baoWrite('PUT',
|
|
186
|
+
yield* baoWrite('PUT', path, write.body);
|
|
173
187
|
}
|
|
174
188
|
const after = yield* readRole(news);
|
|
175
189
|
if (after === undefined) {
|
|
176
190
|
return yield* Effect.die(
|
|
177
191
|
new Error(
|
|
178
|
-
`Bao.ProxmoxRole ${
|
|
192
|
+
`Bao.ProxmoxRole ${path}: write returned no error ` +
|
|
179
193
|
'but the role is still absent.',
|
|
180
194
|
),
|
|
181
195
|
);
|
|
@@ -197,7 +211,9 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
197
211
|
* So this is an availability change for every consumer of `creds/<name>` and NOT a
|
|
198
212
|
* containment action: to contain a leaked credential, revoke the lease (which deletes
|
|
199
213
|
* the PVE token) or delete the token in PVE. Deleting the role only hides the door it
|
|
200
|
-
* came through.
|
|
214
|
+
* came through. The plugin's own test pins this (homeflare-openbao-plugins,
|
|
215
|
+
* TestDeletingARoleLeavesItsOutstandingLeasesRevocable): the delete is one storage
|
|
216
|
+
* delete, and each lease stays revocable without the role.
|
|
201
217
|
*
|
|
202
218
|
* ⚠️ IDEMPOTENT AS ALCHEMY REQUIRES — already gone is success. It used to get that by
|
|
203
219
|
* going through `baoReadText`, which discarded the exit code entirely, so a REFUSED
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The swap the plan-time guard cannot see: a NEW declaration of a name that is live. 🔴 MEASURED
|
|
3
|
+
* 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts). With no state row, the engine asks
|
|
4
|
+
* `read`, which finds the object and answers plain attributes, so Alchemy silently adopts it
|
|
5
|
+
* (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts). Nothing moved, so
|
|
6
|
+
* `judgeMove` never runs. Under `RemovalPolicy.destroy()` the old owner's delete then runs in
|
|
7
|
+
* Apply's phase 2, after the adoption has written, and removes the object the new declaration now
|
|
8
|
+
* claims. The deploy is green.
|
|
9
|
+
*
|
|
10
|
+
* ★ These pin that, and the two ways round it REPLACE.md gives: `renamedFrom` for a logical id that
|
|
11
|
+
* changes, and a second deploy for a name another resource is leaving. If one of the hazards stops
|
|
12
|
+
* reproducing, the engine changed: re-read REPLACE.md before relaxing it.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
15
|
+
*/
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import { renamedFrom } from 'alchemy';
|
|
19
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import { FAMILIES, rowOf, withEstate } from './fake-families.ts';
|
|
22
|
+
import { writesOf } from './fake-stack.ts';
|
|
23
|
+
|
|
24
|
+
const { destroy } = RemovalPolicy;
|
|
25
|
+
|
|
26
|
+
/** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
|
|
27
|
+
const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
|
|
28
|
+
const REFUSES_DELETE = /Bao\.MfaLoginEnforcement/;
|
|
29
|
+
|
|
30
|
+
describe('a logical id changed without renamedFrom, under destroy', () => {
|
|
31
|
+
for (const row of FAMILIES) {
|
|
32
|
+
it(`${row.family}: the orphan delete removes what the new id just adopted`, async () => {
|
|
33
|
+
await withEstate(async (stack, estate) => {
|
|
34
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
35
|
+
const again = stack.deploy(row.declare('Z', 'a', '15m', destroy));
|
|
36
|
+
if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
|
|
37
|
+
else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
|
|
38
|
+
assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('the ways round it', () => {
|
|
45
|
+
const row = rowOf('Bao.AuthRole');
|
|
46
|
+
|
|
47
|
+
it('renamedFrom migrates the state row instead: an update that deletes nothing', async () => {
|
|
48
|
+
await withEstate(async (stack, estate, seen) => {
|
|
49
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
50
|
+
seen.length = 0;
|
|
51
|
+
const moved = row.declare('Z', 'a', '15m', destroy).pipe(renamedFrom('X'));
|
|
52
|
+
assert.deepEqual(await stack.deploy(moved), { Z: 'update' });
|
|
53
|
+
assert.deepEqual(writesOf(seen), []);
|
|
54
|
+
assert.ok(row.has(estate, 'a'));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a name another resource moves off is lost when taken in the same deploy', async () => {
|
|
59
|
+
await withEstate(async (stack, estate) => {
|
|
60
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
61
|
+
const both = Effect.gen(function* () {
|
|
62
|
+
yield* row.declare('X', 'b', '15m', destroy);
|
|
63
|
+
yield* row.declare('Z', 'a', '30m', destroy);
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(await stack.deploy(both), { X: 'replace', Z: 'adopted' });
|
|
66
|
+
assert.ok(row.has(estate, 'b') && !row.has(estate, 'a'));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('and survives when taken in the deploy after the move', async () => {
|
|
71
|
+
await withEstate(async (stack, estate) => {
|
|
72
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
73
|
+
await stack.deploy(row.declare('X', 'b', '15m', destroy));
|
|
74
|
+
const both = Effect.gen(function* () {
|
|
75
|
+
yield* row.declare('X', 'b', '15m', destroy);
|
|
76
|
+
yield* row.declare('Z', 'a', '30m', destroy);
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(await stack.deploy(both), { X: 'noop', Z: 'create' });
|
|
79
|
+
assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|