@homeflare/alchemy 0.8.0 → 0.10.1
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 +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +370 -0
- package/dist/verify/cli.js.map +17 -0
- package/dist/verify/fake-engine.d.ts +65 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +347 -0
- package/dist/verify/index.js.map +16 -0
- package/dist/verify/plan-scope.d.ts +14 -0
- package/dist/verify/plan-scope.d.ts.map +1 -0
- package/dist/verify/recheck.d.ts +42 -0
- package/dist/verify/recheck.d.ts.map +1 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +67 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +43 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +119 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +182 -0
- package/src/verify/index.ts +37 -0
- package/src/verify/plan-scope.ts +34 -0
- package/src/verify/recheck.test.ts +56 -0
- package/src/verify/recheck.ts +71 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +210 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +160 -0
- package/src/verify/verify.test.ts +146 -0
- package/src/verify/verify.ts +185 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deploy killed BEFORE a create's claim ran leaves a `creating` (or `replacing`) row that proves
|
|
3
|
+
* nothing: its create never asked whose object sat at its identity. Found by the red team on
|
|
4
|
+
* 2026-09-21, through Alchemy's own plan and apply, before ownership/whole.ts and the proof in
|
|
5
|
+
* ownership/resume.ts:
|
|
6
|
+
* · an Output IDENTITY — every role family read an empty name, found nothing, "resumed" the create
|
|
7
|
+
* and wrote over another owner's object;
|
|
8
|
+
* · an Output KNOB — Bao.Mount and Bao.AuthMethod read the hole as "not managed", adopted another
|
|
9
|
+
* owner's mount in the recovery read and tuned it;
|
|
10
|
+
* · a REPLACE killed before its write — the next deploy wrote over whatever another owner had put
|
|
11
|
+
* at the new identity since.
|
|
12
|
+
* Each is now refused, writing nothing; `--adopt` resumes it, as Alchemy offers for a create.
|
|
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 * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { BaoAuthRole } from './auth-role.ts';
|
|
21
|
+
import { type Seen, refuseWriteOnce } from './fake-bao.ts';
|
|
22
|
+
import { CORE, type CoreRow, withCore } from './fake-core.ts';
|
|
23
|
+
import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
|
|
24
|
+
import { writesOf } from './fake-stack.ts';
|
|
25
|
+
import { BaoMount } from './mount.ts';
|
|
26
|
+
import { BaoPkiRole } from './pki-role.ts';
|
|
27
|
+
|
|
28
|
+
const REFUSED =
|
|
29
|
+
/Cannot resume creating resource 'Z'|: already exists, and this stack holds no state/s;
|
|
30
|
+
const RESUME_REFUSED = /already exists, and this stack holds no state.*Deploy with --adopt/s;
|
|
31
|
+
|
|
32
|
+
/** Writes other than the upstream's. */
|
|
33
|
+
const writesBut = (seen: readonly Seen[], upstreamPath: string) =>
|
|
34
|
+
writesOf(seen).filter((line) => !line.endsWith(upstreamPath));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `Z`'s NAME is an Output: an upstream named `a` hands it over. An AppRole, or — for Bao.AuthRole,
|
|
38
|
+
* whose own `a` that would be — a PKI role.
|
|
39
|
+
*/
|
|
40
|
+
const namedByUpstream = (row: Family) => {
|
|
41
|
+
const approle = row.family !== 'Bao.AuthRole';
|
|
42
|
+
const path = approle ? '/auth/approle/role/a' : '/pki/roles/a';
|
|
43
|
+
const body = Effect.gen(function* () {
|
|
44
|
+
const up = approle
|
|
45
|
+
? yield* BaoAuthRole('Upstream', {
|
|
46
|
+
name: 'a',
|
|
47
|
+
secretIdTtl: '24h',
|
|
48
|
+
tokenMaxTtl: '1h',
|
|
49
|
+
tokenPolicies: ['default'],
|
|
50
|
+
tokenTtl: '15m',
|
|
51
|
+
})
|
|
52
|
+
: yield* BaoPkiRole('Upstream', {
|
|
53
|
+
allowedDomains: ['x.test'],
|
|
54
|
+
maxTtl: '1h',
|
|
55
|
+
name: 'a',
|
|
56
|
+
ttl: '30m',
|
|
57
|
+
});
|
|
58
|
+
yield* row.declare('Z', up.name, '15m');
|
|
59
|
+
});
|
|
60
|
+
return { body, path };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (const row of FAMILIES) {
|
|
64
|
+
describe(`${row.family}: a create killed before its claim, its name an Output`, () => {
|
|
65
|
+
it('never writes over the object another stack owns; --adopt takes it over', async () => {
|
|
66
|
+
const { body, path } = namedByUpstream(row);
|
|
67
|
+
const fault = refuseWriteOnce(path);
|
|
68
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
69
|
+
await owner.deploy(row.declare('X', 'a', '30m'));
|
|
70
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
71
|
+
seen.length = 0;
|
|
72
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
73
|
+
assert.deepEqual(writesBut(seen, path), []);
|
|
74
|
+
await stack.deploy(body, { adopt: true });
|
|
75
|
+
}, fault);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
|
|
80
|
+
it('never adopts the object another stack owns', async () => {
|
|
81
|
+
const fault = refuseWriteOnce('/upstream');
|
|
82
|
+
const body = Effect.gen(function* () {
|
|
83
|
+
yield* row.declare('Z', 'a', (yield* upstream('15m')).tokenTtl);
|
|
84
|
+
});
|
|
85
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
86
|
+
await owner.deploy(row.declare('X', 'a', '30m'));
|
|
87
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
88
|
+
seen.length = 0;
|
|
89
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
90
|
+
assert.deepEqual(writesBut(seen, '/upstream'), []);
|
|
91
|
+
}, fault);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `Z` at the row's fixed identity, its knob an upstream AppRole's `tokenTtl`. */
|
|
97
|
+
const pendingCore = (row: CoreRow) =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
100
|
+
name: 'upstream',
|
|
101
|
+
secretIdTtl: '24h',
|
|
102
|
+
tokenMaxTtl: '1h',
|
|
103
|
+
tokenPolicies: ['default'],
|
|
104
|
+
tokenTtl: '15m',
|
|
105
|
+
});
|
|
106
|
+
yield* row.declare('Z', up.tokenTtl);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const row of CORE) {
|
|
110
|
+
describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
|
|
111
|
+
it('never adopts — or tunes — the object another stack owns; --adopt takes it over', async () => {
|
|
112
|
+
const fault = refuseWriteOnce('/upstream');
|
|
113
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
114
|
+
await owner.deploy(row.declare('X', '30m'));
|
|
115
|
+
await assert.rejects(stack.deploy(pendingCore(row)), /injected/);
|
|
116
|
+
seen.length = 0;
|
|
117
|
+
await assert.rejects(stack.deploy(pendingCore(row)), REFUSED);
|
|
118
|
+
assert.deepEqual(writesBut(seen, '/upstream'), []);
|
|
119
|
+
await stack.deploy(pendingCore(row), { adopt: true });
|
|
120
|
+
}, fault);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('Bao.Mount: a create killed before its claim, its path an Output', () => {
|
|
126
|
+
it('never tunes the mount another stack owns; --adopt takes it over', async () => {
|
|
127
|
+
const fault = refuseWriteOnce('/auth/approle/role/kv');
|
|
128
|
+
const body = Effect.gen(function* () {
|
|
129
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
130
|
+
name: 'kv',
|
|
131
|
+
secretIdTtl: '24h',
|
|
132
|
+
tokenMaxTtl: '1h',
|
|
133
|
+
tokenPolicies: ['default'],
|
|
134
|
+
tokenTtl: '15m',
|
|
135
|
+
});
|
|
136
|
+
yield* BaoMount('Z', { defaultLeaseTtl: '15m', path: up.name, type: 'kv' });
|
|
137
|
+
});
|
|
138
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
139
|
+
await owner.deploy(BaoMount('X', { defaultLeaseTtl: '30m', path: 'kv', type: 'kv' }));
|
|
140
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
141
|
+
seen.length = 0;
|
|
142
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
143
|
+
assert.deepEqual(writesBut(seen, '/auth/approle/role/kv'), []);
|
|
144
|
+
await stack.deploy(body, { adopt: true });
|
|
145
|
+
}, fault);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
|
|
150
|
+
for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
|
|
151
|
+
describe(`${row.family}: a replace killed before its write`, () => {
|
|
152
|
+
it('never writes over what another stack put at the new identity since; --adopt does', async () => {
|
|
153
|
+
const suffix = row.family === 'Bao.JwtAuthConfig' ? '/b/config' : '/b';
|
|
154
|
+
const fault = refuseWriteOnce(suffix);
|
|
155
|
+
const { retain } = RemovalPolicy;
|
|
156
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
157
|
+
await stack.deploy(row.declare('X', 'a', '15m', retain));
|
|
158
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
|
|
159
|
+
await owner.deploy(row.declare('O', 'b', '30m'));
|
|
160
|
+
seen.length = 0;
|
|
161
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), RESUME_REFUSED);
|
|
162
|
+
assert.deepEqual(writesOf(seen), []);
|
|
163
|
+
await stack.deploy(row.declare('X', 'b', '15m', retain), { adopt: true });
|
|
164
|
+
assert.notDeepEqual(writesOf(seen), []);
|
|
165
|
+
}, fault);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash recovery survives the ownership rule: a deploy that dies after its write and before its
|
|
3
|
+
* commit leaves a `creating` (or `replacing`) row and a live object, and the next deploy must
|
|
4
|
+
* finish it WITHOUT `--adopt` — the object is ours (ownership/probe.ts, ownership/resume.ts).
|
|
5
|
+
* The crash is injected: the first read after a write answers 500, which is exactly a reconcile
|
|
6
|
+
* whose write landed and whose read-back, and so its commit, never did.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
13
|
+
import { type Seen, crashAfterWrite } from './fake-bao.ts';
|
|
14
|
+
import type { Estate } from './fake-engines-roles.ts';
|
|
15
|
+
import { FAMILIES, withEstate } from './fake-families.ts';
|
|
16
|
+
import { type FakeStack, writesOf } from './fake-stack.ts';
|
|
17
|
+
|
|
18
|
+
const RESUME_REFUSED = /Cannot resume creating resource 'X'.*--adopt/s;
|
|
19
|
+
|
|
20
|
+
type Run = (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>;
|
|
21
|
+
const withCrashes = (body: (crashNext: () => void) => Run) => {
|
|
22
|
+
const fault = crashAfterWrite();
|
|
23
|
+
return withEstate(body(fault.crashNext), fault.wrap);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (const row of FAMILIES) {
|
|
27
|
+
describe(`${row.family}: an interrupted create`, () => {
|
|
28
|
+
it('is resumed without --adopt when the object is what it wrote; nothing is rewritten', async () => {
|
|
29
|
+
await withCrashes((crashNext) => async (stack, estate, seen) => {
|
|
30
|
+
crashNext();
|
|
31
|
+
await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), /injected/);
|
|
32
|
+
assert.ok(row.has(estate, 'a'));
|
|
33
|
+
seen.length = 0;
|
|
34
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'a', '15m')), { X: 'create' });
|
|
35
|
+
assert.deepEqual(writesOf(seen), []);
|
|
36
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'a', '15m')), { X: 'noop' });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is refused when the object has since changed, and resumed with --adopt', async () => {
|
|
41
|
+
await withCrashes((crashNext) => async (stack, _estate, _seen, owner) => {
|
|
42
|
+
crashNext();
|
|
43
|
+
await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), /injected/);
|
|
44
|
+
// ★ Someone else rewrites it in between: another stack, taking it over with --adopt.
|
|
45
|
+
await owner.deploy(row.declare('O', 'a', '30m'), { adopt: true });
|
|
46
|
+
await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), RESUME_REFUSED);
|
|
47
|
+
const resumed = await stack.deploy(row.declare('X', 'a', '15m'), { adopt: true });
|
|
48
|
+
assert.deepEqual(resumed, { X: 'create' });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
|
|
55
|
+
for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
|
|
56
|
+
describe(`${row.family}: an interrupted replace`, () => {
|
|
57
|
+
it('is resumed without --adopt: the new generation it wrote is not taken over', async () => {
|
|
58
|
+
await withCrashes((crashNext) => async (stack, estate) => {
|
|
59
|
+
const { retain } = RemovalPolicy;
|
|
60
|
+
await stack.deploy(row.declare('X', 'a', '15m', retain));
|
|
61
|
+
crashNext();
|
|
62
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
|
|
63
|
+
assert.ok(row.has(estate, 'b'));
|
|
64
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'b', '15m', retain)), {
|
|
65
|
+
X: 'replace',
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'b', '15m', retain)), { X: 'noop' });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as Effect from 'effect/Effect';
|
|
8
8
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
9
|
+
import type { Claim } from '../ownership/adopt.ts';
|
|
9
10
|
import {
|
|
10
11
|
type BaoAuthMethodAttributes,
|
|
11
12
|
type BaoAuthMethodProps,
|
|
@@ -36,22 +37,32 @@ const moveTarget = (props: BaoAuthMethodProps): MoveTarget => ({
|
|
|
36
37
|
wire: (path) => `auth/${authPath(path)}`,
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Converge one auth method. `stated` is the path in Alchemy's state, if any.
|
|
42
|
+
* ⛔ `claim`: the same first-reconcile takeover refusal as reconcileMount's (auth-method.ts passes it).
|
|
43
|
+
*/
|
|
40
44
|
export const reconcileAuthMethod = (
|
|
41
45
|
news: BaoAuthMethodProps,
|
|
42
46
|
stated: string | undefined,
|
|
43
47
|
options?: RemountOptions,
|
|
48
|
+
claim?: Claim,
|
|
44
49
|
): Effect.Effect<BaoAuthMethodAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
45
50
|
Effect.gen(function* () {
|
|
46
51
|
const move = planMove('Bao.AuthMethod', stated, news.path, news.remountFrom);
|
|
47
52
|
if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
|
|
53
|
+
const first = stated === undefined ? claim : undefined;
|
|
54
|
+
if (first !== undefined && move.kind === 'move') {
|
|
55
|
+
const source = yield* readMethod({ ...news, path: move.from });
|
|
56
|
+
if (source !== undefined)
|
|
57
|
+
yield* first(`Bao.AuthMethod ${move.from} (the remountFrom source)`);
|
|
58
|
+
}
|
|
48
59
|
if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
|
|
49
60
|
|
|
50
61
|
let live = yield* readMethod(news);
|
|
51
62
|
if (live === undefined) {
|
|
52
63
|
yield* enableAuthMethod(news);
|
|
53
64
|
if (wantsTune(news)) yield* tuneAuthMethod(news);
|
|
54
|
-
} else
|
|
65
|
+
} else {
|
|
55
66
|
if (live.type !== news.type) {
|
|
56
67
|
return yield* Effect.die(
|
|
57
68
|
new Error(
|
|
@@ -60,7 +71,8 @@ export const reconcileAuthMethod = (
|
|
|
60
71
|
),
|
|
61
72
|
);
|
|
62
73
|
}
|
|
63
|
-
yield*
|
|
74
|
+
if (first !== undefined) yield* first(`Bao.AuthMethod ${authPath(news.path)}`);
|
|
75
|
+
if (!matches(live, news)) yield* tuneAuthMethod(news);
|
|
64
76
|
}
|
|
65
77
|
live = yield* readMethod(news);
|
|
66
78
|
if (live === undefined) {
|
|
@@ -19,6 +19,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
19
19
|
import * as Provider from 'alchemy/Provider';
|
|
20
20
|
import * as Effect from 'effect/Effect';
|
|
21
21
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
22
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
23
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
24
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
22
25
|
import {
|
|
23
26
|
type BaoAuthMethodAttributes,
|
|
24
27
|
type BaoAuthMethodProps,
|
|
@@ -54,12 +57,17 @@ export const BaoAuthMethodProvider = () =>
|
|
|
54
57
|
*/
|
|
55
58
|
list: () => Effect.succeed([]),
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
61
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
62
|
+
const found = yield* readMethod(olds);
|
|
63
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
64
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
59
65
|
}),
|
|
60
66
|
|
|
61
67
|
diff: Effect.fn(function* ({ news, output }) {
|
|
62
|
-
|
|
68
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
69
|
+
if (output === undefined) return undefined;
|
|
70
|
+
if (!isResolved(news)) return undefined;
|
|
63
71
|
/**
|
|
64
72
|
* ⛔ A CHANGED PATH FAILS HERE unless `remountFrom` names the old one — the same trap
|
|
65
73
|
* Bao.Mount had: reading the new path finds nothing, plans `update`, and enables an
|
|
@@ -81,8 +89,9 @@ export const BaoAuthMethodProvider = () =>
|
|
|
81
89
|
}),
|
|
82
90
|
|
|
83
91
|
/** The body, and the move, live in auth-method-reconcile.ts. */
|
|
84
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
85
|
-
|
|
92
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
93
|
+
const claim = claimFor({ fqn, instanceId, output });
|
|
94
|
+
return yield* reconcileAuthMethod(news, output?.path, undefined, claim);
|
|
86
95
|
}),
|
|
87
96
|
|
|
88
97
|
/**
|
|
@@ -94,5 +103,5 @@ export const BaoAuthMethodProvider = () =>
|
|
|
94
103
|
return undefined;
|
|
95
104
|
}),
|
|
96
105
|
}),
|
|
97
|
-
),
|
|
106
|
+
).pipe(Effect.map(provingResumes)),
|
|
98
107
|
);
|
package/src/openbao/auth-role.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
14
14
|
import * as Provider from 'alchemy/Provider';
|
|
15
15
|
import * as Effect from 'effect/Effect';
|
|
16
16
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
17
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
18
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
19
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
17
20
|
import {
|
|
18
21
|
type BaoAuthRoleAttributes,
|
|
19
22
|
type BaoAuthRoleProps,
|
|
@@ -66,8 +69,11 @@ export const BaoAuthRoleProvider = () =>
|
|
|
66
69
|
*/
|
|
67
70
|
list: () => Effect.succeed([]),
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
73
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
74
|
+
const found = yield* readRole(olds);
|
|
75
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
76
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
71
77
|
}),
|
|
72
78
|
|
|
73
79
|
/**
|
|
@@ -87,6 +93,7 @@ export const BaoAuthRoleProvider = () =>
|
|
|
87
93
|
* opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
|
|
88
94
|
*/
|
|
89
95
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
96
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
90
97
|
if (output === undefined) return undefined;
|
|
91
98
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
92
99
|
if (!isResolved(news)) return undefined;
|
|
@@ -96,10 +103,13 @@ export const BaoAuthRoleProvider = () =>
|
|
|
96
103
|
return { action: 'update' } as const;
|
|
97
104
|
}),
|
|
98
105
|
|
|
99
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
106
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
100
107
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
101
108
|
yield* guardRename(IDENTITY, news, output);
|
|
102
109
|
const live = yield* readRole(news);
|
|
110
|
+
const path = readPath(news.name);
|
|
111
|
+
if (live !== undefined)
|
|
112
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.AuthRole ${path}`);
|
|
103
113
|
if (live === undefined || !matches(live, news)) {
|
|
104
114
|
yield* baoWrite('PUT', readPath(news.name), writeBody(news));
|
|
105
115
|
}
|
|
@@ -130,5 +140,5 @@ export const BaoAuthRoleProvider = () =>
|
|
|
130
140
|
return undefined;
|
|
131
141
|
}),
|
|
132
142
|
}),
|
|
133
|
-
),
|
|
143
|
+
).pipe(Effect.map(provingResumes)),
|
|
134
144
|
);
|
|
@@ -21,6 +21,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
21
21
|
import * as Provider from 'alchemy/Provider';
|
|
22
22
|
import * as Effect from 'effect/Effect';
|
|
23
23
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
24
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
25
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
26
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
24
27
|
import { groupKey, scopeOfResource } from './cloudflare-group-scope.ts';
|
|
25
28
|
import { CloudflarePermissionGroups, type GroupRef } from './cloudflare-permission-groups.ts';
|
|
26
29
|
import { resolvePolicies } from './cloudflare-policy.ts';
|
|
@@ -106,9 +109,15 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
106
109
|
*/
|
|
107
110
|
list: () => Effect.succeed([]),
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
113
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
110
114
|
const live = yield* readCloudflareRole(olds.mount, olds.name);
|
|
111
|
-
|
|
115
|
+
const found = live === undefined ? undefined : attributesOf(olds, live);
|
|
116
|
+
const ours = Effect.gen(function* () {
|
|
117
|
+
if (live === undefined || refusalOf(olds) !== undefined) return false;
|
|
118
|
+
return differences(olds, yield* resolved(olds), live).length === 0;
|
|
119
|
+
});
|
|
120
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
112
121
|
}),
|
|
113
122
|
|
|
114
123
|
/**
|
|
@@ -129,6 +138,7 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
129
138
|
*/
|
|
130
139
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
131
140
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
141
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
132
142
|
if (output === undefined) return undefined;
|
|
133
143
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
134
144
|
if (!isResolved(news)) return undefined;
|
|
@@ -145,7 +155,7 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
145
155
|
: ({ action: 'update' } as const);
|
|
146
156
|
}),
|
|
147
157
|
|
|
148
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
158
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
149
159
|
const path = rolePath(news.mount, news.name);
|
|
150
160
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
151
161
|
yield* guardRename(IDENTITY, news, output);
|
|
@@ -155,6 +165,9 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
155
165
|
}
|
|
156
166
|
const policies = yield* resolved(news);
|
|
157
167
|
const live = yield* readCloudflareRole(news.mount, news.name);
|
|
168
|
+
if (live !== undefined) {
|
|
169
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.CloudflareRole ${path}`);
|
|
170
|
+
}
|
|
158
171
|
/**
|
|
159
172
|
* ⛔ ADOPTING A ROLE THAT ALREADY MATCHES MUST NOT REWRITE IT. Alchemy's `adopted` action
|
|
160
173
|
* routes through reconcile (policy.ts has the Apply.ts reading), so without this guard
|
|
@@ -199,5 +212,5 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
199
212
|
return undefined;
|
|
200
213
|
}),
|
|
201
214
|
});
|
|
202
|
-
}),
|
|
215
|
+
}).pipe(Effect.map(provingResumes)),
|
|
203
216
|
);
|
package/src/openbao/fake-bao.ts
CHANGED
|
@@ -82,3 +82,55 @@ export const runFailure = <A, E>(
|
|
|
82
82
|
env: BaoEnvironment,
|
|
83
83
|
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
|
|
84
84
|
) => Effect.runPromise(provided(env, Effect.flip(effect)));
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An injected crash between a write and its commit. Once armed, `crashNext()` fails the first read
|
|
88
|
+
* that follows a write with a 500 — a reconcile whose write landed and whose read-back, and so the
|
|
89
|
+
* engine's commit, never did — and `crashNext('write')` lands the next write and then answers it
|
|
90
|
+
* 500, for a reconcile that reads nothing back (Bao.Policy). Either leaves the `creating` /
|
|
91
|
+
* `replacing` row a killed deploy leaves.
|
|
92
|
+
*/
|
|
93
|
+
export const crashAfterWrite = () => {
|
|
94
|
+
let armed: 'read-back' | 'write' | undefined;
|
|
95
|
+
let wrote = false;
|
|
96
|
+
const CRASH: Reply = { json: { errors: ['injected: the deploy died here'] }, status: 500 };
|
|
97
|
+
return {
|
|
98
|
+
crashNext: (at: 'read-back' | 'write' = 'read-back') => {
|
|
99
|
+
armed = at;
|
|
100
|
+
wrote = false;
|
|
101
|
+
},
|
|
102
|
+
wrap:
|
|
103
|
+
(answer: (seen: Seen) => Reply) =>
|
|
104
|
+
(seen: Seen): Reply => {
|
|
105
|
+
if (armed === 'write' && seen.method !== 'GET') {
|
|
106
|
+
armed = undefined;
|
|
107
|
+
answer(seen);
|
|
108
|
+
return CRASH;
|
|
109
|
+
}
|
|
110
|
+
if (armed !== undefined && seen.method !== 'GET') wrote = true;
|
|
111
|
+
if (armed !== undefined && wrote && seen.method === 'GET') {
|
|
112
|
+
armed = undefined;
|
|
113
|
+
return CRASH;
|
|
114
|
+
}
|
|
115
|
+
return answer(seen);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A deploy killed BEFORE a resource's reconcile ran: the first write to a path ending in `suffix` is
|
|
122
|
+
* refused (500) without landing. Aimed at an upstream, it fails every create waiting on it after
|
|
123
|
+
* Apply has committed their `creating` rows — rows whose create never asked whose object sat at
|
|
124
|
+
* its identity (ownership/whole.ts).
|
|
125
|
+
*/
|
|
126
|
+
export const refuseWriteOnce = (suffix: string) => {
|
|
127
|
+
let armed = true;
|
|
128
|
+
return (answer: (seen: Seen) => Reply) =>
|
|
129
|
+
(seen: Seen): Reply => {
|
|
130
|
+
if (armed && seen.method !== 'GET' && seen.path.endsWith(suffix)) {
|
|
131
|
+
armed = false;
|
|
132
|
+
return { json: { errors: ['injected: the upstream was refused'] }, status: 500 };
|
|
133
|
+
}
|
|
134
|
+
return answer(seen);
|
|
135
|
+
};
|
|
136
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five families fake-families.ts does not cover — Bao.Policy, Bao.CloudflareRole,
|
|
3
|
+
* Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — as rows of one table over one routed fake, so the
|
|
4
|
+
* ownership tests (adopt-core.test.ts) drive each through Alchemy's own plan and apply. Each row
|
|
5
|
+
* declares one object whose identity is fixed; `knob` is a duration-like prop that is NOT its
|
|
6
|
+
* identity, so an Output can stand in for it and a second owner can change it.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
13
|
+
import type * as Output from 'alchemy/Output';
|
|
14
|
+
import { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
|
|
15
|
+
import { BaoAuthRoleProvider } from './auth-role.ts';
|
|
16
|
+
import { groupKey } from './cloudflare-group-scope.ts';
|
|
17
|
+
import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
|
|
18
|
+
import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
19
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
20
|
+
import { aclPolicies, cloudflareRoles, fakePolicyProviders, proxmoxRoles } from './fake-engines.ts';
|
|
21
|
+
import { wireRoles } from './fake-engines-roles.ts';
|
|
22
|
+
import { liveTable } from './fake-mounts.ts';
|
|
23
|
+
import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
|
|
24
|
+
import { BaoMount, BaoMountProvider } from './mount.ts';
|
|
25
|
+
import { BaoPolicy } from './policy.ts';
|
|
26
|
+
import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
|
|
27
|
+
|
|
28
|
+
/** Every group resolves, to an id named after it — these tests are about ownership, not groups. */
|
|
29
|
+
const groups = Layer.succeed(CloudflarePermissionGroups, {
|
|
30
|
+
resolve: (_mount, refs) =>
|
|
31
|
+
Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const coreProviders = Layer.mergeAll(
|
|
35
|
+
fakePolicyProviders,
|
|
36
|
+
BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
|
|
37
|
+
BaoProxmoxRoleProvider(),
|
|
38
|
+
BaoMountProvider(),
|
|
39
|
+
BaoAuthMethodProvider(),
|
|
40
|
+
// ★ For `upstream`: an AppRole whose `tokenTtl` is the Output a knob is fed.
|
|
41
|
+
BaoAuthRoleProvider(),
|
|
42
|
+
FetchHttpClient.layer,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Every store behind one answer, routed by path. */
|
|
46
|
+
export const coreEstate = () => {
|
|
47
|
+
const stores = {
|
|
48
|
+
auth: liveTable('sys/auth', {}),
|
|
49
|
+
cloudflare: cloudflareRoles(),
|
|
50
|
+
mounts: liveTable('sys/mounts', {}),
|
|
51
|
+
policies: aclPolicies(),
|
|
52
|
+
proxmox: proxmoxRoles(),
|
|
53
|
+
roles: wireRoles(),
|
|
54
|
+
};
|
|
55
|
+
const answer = (seen: Seen): Reply => {
|
|
56
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
57
|
+
if (path.startsWith('sys/policies/acl/')) return stores.policies(seen);
|
|
58
|
+
if (path.startsWith('sys/auth')) return stores.auth.answer(seen);
|
|
59
|
+
if (path.startsWith('sys/mounts') || path.startsWith('sys/remount')) {
|
|
60
|
+
return stores.mounts.answer(seen);
|
|
61
|
+
}
|
|
62
|
+
if (path.startsWith('cloudflare-acme-dns/')) return stores.cloudflare(seen);
|
|
63
|
+
if (path.startsWith('proxmox-lab/')) return stores.proxmox(seen);
|
|
64
|
+
return stores.roles(seen);
|
|
65
|
+
};
|
|
66
|
+
return Object.assign(answer, stores);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type CoreEstate = ReturnType<typeof coreEstate>;
|
|
70
|
+
|
|
71
|
+
type Text = string | Output.Output<string>;
|
|
72
|
+
|
|
73
|
+
export interface CoreRow {
|
|
74
|
+
readonly family: string;
|
|
75
|
+
/** One resource, logical id `id`, at the row's fixed identity. */
|
|
76
|
+
readonly declare: (id: string, knob: Text) => StackBody;
|
|
77
|
+
/** Whether the fake holds the object. */
|
|
78
|
+
readonly has: (estate: CoreEstate) => boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const CORE: readonly CoreRow[] = [
|
|
82
|
+
{
|
|
83
|
+
// ★ The fragments directory IS the content (fake-engines.ts), so a knob changes the policy.
|
|
84
|
+
declare: (id, knob) => BaoPolicy(id, { fragments: knob, name: 'app' }),
|
|
85
|
+
family: 'Bao.Policy',
|
|
86
|
+
has: (estate) => estate.policies.live.has('sys/policies/acl/app'),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
declare: (id, knob) =>
|
|
90
|
+
BaoCloudflareRole(id, {
|
|
91
|
+
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
92
|
+
maxTtl: '1h',
|
|
93
|
+
mount: 'cloudflare-acme-dns',
|
|
94
|
+
name: 'dns-read',
|
|
95
|
+
policies: [
|
|
96
|
+
{
|
|
97
|
+
effect: 'allow',
|
|
98
|
+
groupOrder: 'declared',
|
|
99
|
+
groups: ['DNS Read'],
|
|
100
|
+
resource: 'com.cloudflare.api.account.zone.z1',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
ttl: knob,
|
|
104
|
+
}),
|
|
105
|
+
family: 'Bao.CloudflareRole',
|
|
106
|
+
has: (estate) => estate.cloudflare.live.has('cloudflare-acme-dns/roles/dns-read'),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
declare: (id, knob) =>
|
|
110
|
+
BaoProxmoxRole(id, {
|
|
111
|
+
maxTtl: '6h',
|
|
112
|
+
mintUser: 'reader@pve',
|
|
113
|
+
mount: 'proxmox-lab',
|
|
114
|
+
name: 'read',
|
|
115
|
+
ttl: knob,
|
|
116
|
+
}),
|
|
117
|
+
family: 'Bao.ProxmoxRole',
|
|
118
|
+
has: (estate) => estate.proxmox.live.has('proxmox-lab/roles/read'),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
declare: (id, knob) => BaoMount(id, { defaultLeaseTtl: knob, path: 'kv', type: 'kv' }),
|
|
122
|
+
family: 'Bao.Mount',
|
|
123
|
+
has: (estate) => estate.mounts.table.has('kv/'),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
declare: (id, knob) => BaoAuthMethod(id, { defaultLeaseTtl: knob, path: 'jwt', type: 'jwt' }),
|
|
127
|
+
family: 'Bao.AuthMethod',
|
|
128
|
+
has: (estate) => estate.auth.table.has('jwt/'),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/** One stack over a fresh core estate, plus `owner`: a second stack over the same fake. */
|
|
133
|
+
export const withCore = (
|
|
134
|
+
body: (stack: FakeStack, estate: CoreEstate, seen: Seen[], owner: FakeStack) => Promise<void>,
|
|
135
|
+
answer: (estate: CoreEstate) => (seen: Seen) => Reply = (estate) => estate,
|
|
136
|
+
): Promise<void> => {
|
|
137
|
+
const estate = coreEstate();
|
|
138
|
+
return withFakeStack(coreProviders, answer(estate), (stack, bao) =>
|
|
139
|
+
body(stack, estate, bao.seen, fakeStack(coreProviders, { BAO_ADDR: bao.address }, 'Owner')),
|
|
140
|
+
);
|
|
141
|
+
};
|