@homeflare/alchemy 0.7.0 → 0.10.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 +45 -45
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- 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 +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +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 +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.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +642 -49
- package/dist/launchd/index.js.map +24 -14
- 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-validate.d.ts +2 -0
- package/dist/launchd/job-validate.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/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 +14 -5
- 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-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +83 -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-said.d.ts +10 -0
- package/dist/launchd/sudo-said.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-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-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-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-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- 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/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- 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 +12 -5
- 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/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.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-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/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 +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -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 +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -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 +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -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 +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -10
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- 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 +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -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 +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- 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-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- 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 +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -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.ts +19 -15
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- 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 +88 -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 +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -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
|
);
|
|
@@ -5,17 +5,11 @@
|
|
|
5
5
|
* sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
|
|
6
6
|
* TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
|
|
7
7
|
* read hands back that integer, which attributesOf renders as `90d` — the same role.
|
|
8
|
-
*
|
|
8
|
+
* (What the diff calls a rename is pinned in rename-families.test.ts, `foldName`.)
|
|
9
9
|
*/
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { describe, it } from 'node:test';
|
|
12
|
-
import {
|
|
13
|
-
type BaoAuthRoleProps,
|
|
14
|
-
attributesOf,
|
|
15
|
-
isRename,
|
|
16
|
-
matches,
|
|
17
|
-
writeBody,
|
|
18
|
-
} from './auth-role-form.ts';
|
|
12
|
+
import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
|
|
19
13
|
|
|
20
14
|
const PROPS: BaoAuthRoleProps = {
|
|
21
15
|
bindSecretId: true,
|
|
@@ -47,11 +41,3 @@ describe('auth role secret_id TTL', () => {
|
|
|
47
41
|
assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
|
|
48
42
|
});
|
|
49
43
|
});
|
|
50
|
-
|
|
51
|
-
describe('auth role rename', () => {
|
|
52
|
-
it('is a rename only when the lower-cased names differ, as the server stores them', () => {
|
|
53
|
-
assert.equal(isRename('pve-node--node-a', 'pve-node--node-b'), true);
|
|
54
|
-
assert.equal(isRename('Host-Cert', 'host-cert'), false);
|
|
55
|
-
assert.equal(isRename('host-cert', 'host-cert'), false);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
@@ -89,17 +89,6 @@ export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
|
|
|
89
89
|
return body;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
* Whether a changed `name` names a DIFFERENT role — the diff's `replace` question.
|
|
94
|
-
*
|
|
95
|
-
* ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
|
|
96
|
-
* v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key, :1537),
|
|
97
|
-
* so `Host` → `host` is the SAME role: a `replace` there would write it, then — under `destroy` —
|
|
98
|
-
* delete the old generation, which is that same role.
|
|
99
|
-
*/
|
|
100
|
-
export const isRename = (from: string, to: string): boolean =>
|
|
101
|
-
from.toLowerCase() !== to.toLowerCase();
|
|
102
|
-
|
|
103
92
|
const samePolicies = (want: readonly string[], have: readonly string[]) =>
|
|
104
93
|
want.length === have.length && want.every((policy, index) => policy === have[index]);
|
|
105
94
|
|
package/src/openbao/auth-role.ts
CHANGED
|
@@ -5,24 +5,28 @@
|
|
|
5
5
|
* are credential issuance, not configuration, and are OUT OF SCOPE. This declares the
|
|
6
6
|
* role shape an approle login inherits — policy names and TTL strings only.
|
|
7
7
|
*
|
|
8
|
-
* ★ `defaultRemovalPolicy: 'retain'` — deleting a role
|
|
9
|
-
*
|
|
8
|
+
* ★ `defaultRemovalPolicy: 'retain'` — deleting a role locks out every host that logs in with it:
|
|
9
|
+
* its secret_ids go with it (the ⚠️ on `delete` below). Opt in with
|
|
10
|
+
* `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
|
|
10
11
|
*/
|
|
11
12
|
import { Resource } from 'alchemy';
|
|
12
13
|
import { isResolved } from 'alchemy/Diff';
|
|
13
14
|
import * as Provider from 'alchemy/Provider';
|
|
14
15
|
import * as Effect from 'effect/Effect';
|
|
15
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';
|
|
16
20
|
import {
|
|
17
21
|
type BaoAuthRoleAttributes,
|
|
18
22
|
type BaoAuthRoleProps,
|
|
19
23
|
attributesOf,
|
|
20
|
-
isRename,
|
|
21
24
|
matches,
|
|
22
25
|
readPath,
|
|
23
26
|
writeBody,
|
|
24
27
|
} from './auth-role-form.ts';
|
|
25
28
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
29
|
+
import { foldName, guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
|
|
26
30
|
|
|
27
31
|
export type { BaoAuthRoleAttributes, BaoAuthRoleProps };
|
|
28
32
|
|
|
@@ -38,6 +42,14 @@ export const BaoAuthRole = Resource<BaoAuthRole>('Bao.AuthRole', {
|
|
|
38
42
|
defaultRemovalPolicy: 'retain',
|
|
39
43
|
});
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
|
|
47
|
+
* v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key,
|
|
48
|
+
* :1537), so `Host` → `host` is the SAME role: a `replace` there would write it, then — under
|
|
49
|
+
* `destroy` — delete the old generation, which is that same role.
|
|
50
|
+
*/
|
|
51
|
+
const IDENTITY = nameIdentity<BaoAuthRoleAttributes>('Bao.AuthRole', readPath, foldName);
|
|
52
|
+
|
|
41
53
|
const readRole = (props: BaoAuthRoleProps) =>
|
|
42
54
|
Effect.gen(function* () {
|
|
43
55
|
const live = yield* baoRead(readPath(props.name));
|
|
@@ -57,35 +69,47 @@ export const BaoAuthRoleProvider = () =>
|
|
|
57
69
|
*/
|
|
58
70
|
list: () => Effect.succeed([]),
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
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);
|
|
62
77
|
}),
|
|
63
78
|
|
|
64
79
|
/**
|
|
65
80
|
* ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. A role edited in the OpenBao
|
|
66
81
|
* UI is exactly the drift the check-* gates exist to catch.
|
|
67
82
|
*/
|
|
68
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
69
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
83
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
70
84
|
/**
|
|
71
|
-
* ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace
|
|
72
|
-
* Until 2026-09-21 a new `name`
|
|
73
|
-
*
|
|
74
|
-
*
|
|
85
|
+
* ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`,
|
|
86
|
+
* judged before `isResolved(news)` (rename-identity.ts). Until 2026-09-21 a new `name`
|
|
87
|
+
* read nothing, planned `update` and wrote the new role, leaving the old one live
|
|
88
|
+
* under no state record: its secret_ids kept logging in for their whole TTL, and no
|
|
89
|
+
* plan would ever mention it again (REPLACE.md). ⛔ A rename onto a role that exists
|
|
90
|
+
* fails the plan.
|
|
75
91
|
* ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL STAYS LIVE — retain keeps the old
|
|
76
92
|
* generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
|
|
77
93
|
* opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
|
|
78
94
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
95
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
96
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
97
|
+
if (output === undefined) return undefined;
|
|
98
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
99
|
+
if (!isResolved(news)) return undefined;
|
|
81
100
|
const live = yield* readRole(news);
|
|
82
101
|
if (live === undefined) return { action: 'update' } as const;
|
|
83
102
|
if (matches(live, news)) return { action: 'noop' } as const;
|
|
84
103
|
return { action: 'update' } as const;
|
|
85
104
|
}),
|
|
86
105
|
|
|
87
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
106
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
107
|
+
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
108
|
+
yield* guardRename(IDENTITY, news, output);
|
|
88
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}`);
|
|
89
113
|
if (live === undefined || !matches(live, news)) {
|
|
90
114
|
yield* baoWrite('PUT', readPath(news.name), writeBody(news));
|
|
91
115
|
}
|
|
@@ -101,13 +125,20 @@ export const BaoAuthRoleProvider = () =>
|
|
|
101
125
|
}),
|
|
102
126
|
|
|
103
127
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
128
|
+
* ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED. openbao v2.6.2 pathRoleDelete
|
|
129
|
+
* (builtin/credential/approle/path_role.go:1858-1902) deletes every secret_id
|
|
130
|
+
* (flushRoleSecrets, validation.go:420-446), the role_id mapping and the role, so no new
|
|
131
|
+
* login succeeds, and it revokes nothing. A token already issued lives to its TTL; only
|
|
132
|
+
* its renewal fails, because pathLoginRenew (path_login.go:414-438) refuses once the role
|
|
133
|
+
* is gone. So this is not a containment action: to contain a leaked token, revoke it or
|
|
134
|
+
* its accessor. (Corrected 2026-09-21: this said the delete revoked every token.)
|
|
135
|
+
* Idempotent as Alchemy requires — already gone is success — but retain is the default,
|
|
136
|
+
* because every host logging in with the role is locked out at the delete.
|
|
106
137
|
*/
|
|
107
138
|
delete: Effect.fn(function* ({ output }) {
|
|
108
139
|
yield* baoDelete(readPath(output.name));
|
|
109
140
|
return undefined;
|
|
110
141
|
}),
|
|
111
142
|
}),
|
|
112
|
-
),
|
|
143
|
+
).pipe(Effect.map(provingResumes)),
|
|
113
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';
|
|
@@ -38,13 +41,7 @@ import {
|
|
|
38
41
|
readCloudflareRole,
|
|
39
42
|
writeCloudflareRole,
|
|
40
43
|
} from './cloudflare-role-wire.ts';
|
|
41
|
-
import {
|
|
42
|
-
declaredRolePath,
|
|
43
|
-
isMoved,
|
|
44
|
-
judgeMove,
|
|
45
|
-
refuseMovedUpdate,
|
|
46
|
-
triedRolePath,
|
|
47
|
-
} from './rename.ts';
|
|
44
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
48
45
|
|
|
49
46
|
export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
|
|
50
47
|
|
|
@@ -66,6 +63,9 @@ export const BaoCloudflareRole = Resource<BaoCloudflareRole>('Bao.CloudflareRole
|
|
|
66
63
|
defaultRemovalPolicy: 'retain',
|
|
67
64
|
});
|
|
68
65
|
|
|
66
|
+
/** Exact: a trailing `/` on the mount is the same path (`rolePath` trims it). */
|
|
67
|
+
const IDENTITY = roleIdentity<BaoCloudflareRoleAttributes>('Bao.CloudflareRole', rolePath);
|
|
68
|
+
|
|
69
69
|
export const BaoCloudflareRoleProvider = () =>
|
|
70
70
|
Provider.effect(
|
|
71
71
|
BaoCloudflareRole,
|
|
@@ -109,9 +109,15 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
109
109
|
*/
|
|
110
110
|
list: () => Effect.succeed([]),
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
113
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
113
114
|
const live = yield* readCloudflareRole(olds.mount, olds.name);
|
|
114
|
-
|
|
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);
|
|
115
121
|
}),
|
|
116
122
|
|
|
117
123
|
/**
|
|
@@ -131,9 +137,8 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
131
137
|
* and the old id leaves the stack as an orphan delete, which `retain` keeps live.
|
|
132
138
|
*/
|
|
133
139
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
const move = yield* judgeMove('Bao.CloudflareRole', tried, declared, (path) => path);
|
|
140
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
141
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
137
142
|
if (output === undefined) return undefined;
|
|
138
143
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
139
144
|
if (!isResolved(news)) return undefined;
|
|
@@ -150,19 +155,19 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
150
155
|
: ({ action: 'update' } as const);
|
|
151
156
|
}),
|
|
152
157
|
|
|
153
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
158
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
154
159
|
const path = rolePath(news.mount, news.name);
|
|
155
160
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
156
|
-
|
|
157
|
-
if (isMoved(before, path) === true) {
|
|
158
|
-
return yield* refuseMovedUpdate('Bao.CloudflareRole', before, path);
|
|
159
|
-
}
|
|
161
|
+
yield* guardRename(IDENTITY, news, output);
|
|
160
162
|
const refusal = refusalOf(news);
|
|
161
163
|
if (refusal !== undefined) {
|
|
162
164
|
return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
|
|
163
165
|
}
|
|
164
166
|
const policies = yield* resolved(news);
|
|
165
167
|
const live = yield* readCloudflareRole(news.mount, news.name);
|
|
168
|
+
if (live !== undefined) {
|
|
169
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.CloudflareRole ${path}`);
|
|
170
|
+
}
|
|
166
171
|
/**
|
|
167
172
|
* ⛔ ADOPTING A ROLE THAT ALREADY MATCHES MUST NOT REWRITE IT. Alchemy's `adopted` action
|
|
168
173
|
* routes through reconcile (policy.ts has the Apply.ts reading), so without this guard
|
|
@@ -207,5 +212,5 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
207
212
|
return undefined;
|
|
208
213
|
}),
|
|
209
214
|
});
|
|
210
|
-
}),
|
|
215
|
+
}).pipe(Effect.map(provingResumes)),
|
|
211
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
|
+
};
|