@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
|
@@ -13,9 +13,9 @@ import * as Effect from 'effect/Effect';
|
|
|
13
13
|
import * as Layer from 'effect/Layer';
|
|
14
14
|
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
15
|
import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
16
|
-
import type { Seen } from './fake-bao.ts';
|
|
16
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
17
17
|
import { type Estate, fakeEstate } from './fake-engines-roles.ts';
|
|
18
|
-
import { type FakeStack, type StackBody, withFakeStack } from './fake-stack.ts';
|
|
18
|
+
import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
|
|
19
19
|
import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
|
|
20
20
|
import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
|
|
21
21
|
import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
|
|
@@ -38,12 +38,18 @@ export const familyProviders = Layer.mergeAll(
|
|
|
38
38
|
FetchHttpClient.layer,
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* One stack over a fresh estate: every family's provider, and every call the fake saw — plus
|
|
43
|
+
* `owner`, a SECOND stack over the same fake with its own state: another owner of the same names.
|
|
44
|
+
*/
|
|
42
45
|
export const withEstate = (
|
|
43
|
-
body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
|
|
46
|
+
body: (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>,
|
|
47
|
+
answer: (estate: Estate) => (seen: Seen) => Reply = (estate) => estate,
|
|
44
48
|
): Promise<void> => {
|
|
45
49
|
const estate = fakeEstate();
|
|
46
|
-
return withFakeStack(familyProviders, estate, (stack, bao) =>
|
|
50
|
+
return withFakeStack(familyProviders, answer(estate), (stack, bao) =>
|
|
51
|
+
body(stack, estate, bao.seen, fakeStack(familyProviders, { BAO_ADDR: bao.address }, 'Owner')),
|
|
52
|
+
);
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
/** The row for one family, by name. */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake mount table for fake-bao — `sys/mounts` (secrets engines) or `sys/auth` (auth methods):
|
|
3
|
+
* enable, tune, read, the listing a 400 is settled against (mount-wire.ts), and `sys/remount`.
|
|
4
|
+
* Split out of mount-move.test.ts so the engine-level ownership tests drive Bao.Mount and
|
|
5
|
+
* Bao.AuthMethod through the same table.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
8
|
+
*/
|
|
9
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
10
|
+
|
|
11
|
+
type Entry = { type: string; description: string; config: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/** A fresh mount's lease TTLs: 0, "use the system default", as the server reports them. */
|
|
14
|
+
const zero = () => ({ default_lease_ttl: 0, max_lease_ttl: 0 });
|
|
15
|
+
|
|
16
|
+
/** The table keyed `<path>/` as the server keys it; `initial` maps a bare path to its type. */
|
|
17
|
+
export const liveTable = (prefix: string, initial: Record<string, string>) => {
|
|
18
|
+
const entries = new Map<string, Entry>(
|
|
19
|
+
Object.entries(initial).map(([path, type]) => [
|
|
20
|
+
`${path}/`,
|
|
21
|
+
{ config: zero(), description: '', type },
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
const types = () => new Map([...entries].map(([path, entry]) => [path, entry.type]));
|
|
25
|
+
const answer = (seen: Seen): Reply => {
|
|
26
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
27
|
+
if (path === 'sys/remount' && seen.method === 'POST') {
|
|
28
|
+
const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
|
|
29
|
+
const key = `${from.replace(/^auth\//, '')}/`;
|
|
30
|
+
const entry = entries.get(key);
|
|
31
|
+
if (entry === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
|
|
32
|
+
entries.delete(key);
|
|
33
|
+
entries.set(`${to.replace(/^auth\//, '')}/`, entry);
|
|
34
|
+
return { json: { data: { migration_id: 'm-1' } }, status: 200 };
|
|
35
|
+
}
|
|
36
|
+
if (path.startsWith('sys/remount/status/')) {
|
|
37
|
+
return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
|
|
38
|
+
}
|
|
39
|
+
if (path === prefix) return { json: { data: Object.fromEntries(types()) }, status: 200 };
|
|
40
|
+
const tune = path.endsWith('/tune');
|
|
41
|
+
const key = `${path.slice(prefix.length + 1).replace(/\/tune$/, '')}/`;
|
|
42
|
+
if (seen.method === 'POST') {
|
|
43
|
+
const body = JSON.parse(seen.body || '{}') as Record<string, unknown>;
|
|
44
|
+
const entry = entries.get(key) ?? {
|
|
45
|
+
config: zero(),
|
|
46
|
+
description: '',
|
|
47
|
+
type: 'enabled-by-test',
|
|
48
|
+
};
|
|
49
|
+
if (!tune && typeof body['type'] === 'string') entry.type = body['type'];
|
|
50
|
+
if (tune) {
|
|
51
|
+
const { description, ...config } = body;
|
|
52
|
+
if (typeof description === 'string') entry.description = description;
|
|
53
|
+
entry.config = { ...entry.config, ...config };
|
|
54
|
+
}
|
|
55
|
+
entries.set(key, entry);
|
|
56
|
+
return { status: 204 };
|
|
57
|
+
}
|
|
58
|
+
if (seen.method === 'DELETE') {
|
|
59
|
+
entries.delete(key);
|
|
60
|
+
return { status: 204 };
|
|
61
|
+
}
|
|
62
|
+
const entry = entries.get(key);
|
|
63
|
+
return entry === undefined
|
|
64
|
+
? { json: { errors: [`No secret engine mount at ${key}`] }, status: 400 }
|
|
65
|
+
: { json: { data: entry }, status: 200 };
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
answer,
|
|
69
|
+
/** The live table as `<path>/` → type, the shape the move tests compare. */
|
|
70
|
+
get table() {
|
|
71
|
+
return types();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* in the shell running the tests is never read. No provider imports this file.
|
|
17
17
|
*/
|
|
18
18
|
import * as Alchemy from 'alchemy';
|
|
19
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
20
|
+
import { provideFreshArtifactStore } from 'alchemy/Artifacts';
|
|
19
21
|
import * as Effect from 'effect/Effect';
|
|
20
22
|
import * as Layer from 'effect/Layer';
|
|
21
23
|
import type { BaoEnvironment } from './bao-address.ts';
|
|
@@ -28,9 +30,12 @@ export type Planned = Readonly<Record<string, string>>;
|
|
|
28
30
|
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
|
29
31
|
export type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
30
32
|
|
|
33
|
+
/** What the CLI would set for one deploy: `adopt` is `--adopt` (the AdoptPolicy service). */
|
|
34
|
+
export type DeployOptions = { readonly adopt?: boolean };
|
|
35
|
+
|
|
31
36
|
export interface FakeStack {
|
|
32
37
|
/** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
|
|
33
|
-
readonly deploy: (body: StackBody) => Promise<Planned>;
|
|
38
|
+
readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
type Node = { readonly action: string };
|
|
@@ -72,22 +77,30 @@ export const fakeStack = <ROut, E, RIn>(
|
|
|
72
77
|
) => Effect.Effect<PlanView, unknown, never>;
|
|
73
78
|
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
/**
|
|
81
|
+
* ★ ONE ARTIFACT STORE ACROSS PLAN AND APPLY, as Deploy.ts's evalStack provides — the channel a
|
|
82
|
+
* diff uses to tell reconcile it resumes an interrupted create (ownership/resume.ts).
|
|
83
|
+
* ★ `--adopt` reaches registration, plan and apply alike, as the Alchemist session provides it.
|
|
84
|
+
*/
|
|
85
|
+
const deploy = (body: StackBody, options: DeployOptions = {}) =>
|
|
76
86
|
Effect.gen(function* () {
|
|
77
87
|
const compiled = yield* stack(name, { providers, state }, body);
|
|
78
88
|
return yield* Effect.gen(function* () {
|
|
79
89
|
const planned = yield* plan(compiled);
|
|
80
90
|
yield* apply(planned);
|
|
81
91
|
return actionsOf(planned);
|
|
82
|
-
}).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
|
|
92
|
+
}).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
|
|
83
93
|
}).pipe(
|
|
94
|
+
options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
|
|
84
95
|
Effect.provideService(Alchemy.Stage, 'test'),
|
|
85
96
|
Effect.provideService(BaoEnv, env),
|
|
86
97
|
Effect.provide(state),
|
|
87
98
|
Effect.scoped,
|
|
88
99
|
);
|
|
89
100
|
|
|
90
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
|
|
103
|
+
};
|
|
91
104
|
};
|
|
92
105
|
|
|
93
106
|
/**
|
|
@@ -21,6 +21,8 @@ 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 { claimFor } from '../ownership/adopt.ts';
|
|
25
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
24
26
|
import {
|
|
25
27
|
type BaoJwtAuthConfigAttributes,
|
|
26
28
|
type BaoJwtAuthConfigProps,
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
} from './jwt-config-form.ts';
|
|
34
36
|
import { declaredOr } from './rename.ts';
|
|
35
37
|
import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
|
|
36
|
-
import { type RoleSpec, planRole,
|
|
38
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
37
39
|
|
|
38
40
|
export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
|
|
39
41
|
|
|
@@ -78,14 +80,15 @@ export const BaoJwtAuthConfigProvider = () =>
|
|
|
78
80
|
BaoJwtAuthConfig.Provider.of({
|
|
79
81
|
list: () => Effect.succeed([]),
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
83
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
84
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
85
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, jwtConfigSpec(olds));
|
|
84
86
|
}),
|
|
85
87
|
|
|
86
88
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
87
89
|
// ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
|
|
88
90
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
91
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
89
92
|
if (output === undefined) return undefined;
|
|
90
93
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
91
94
|
if (!isResolved(news)) return undefined;
|
|
@@ -93,14 +96,14 @@ export const BaoJwtAuthConfigProvider = () =>
|
|
|
93
96
|
}),
|
|
94
97
|
|
|
95
98
|
/** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
|
|
96
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
99
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
97
100
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
98
101
|
yield* guardRename(IDENTITY, news, output);
|
|
99
|
-
return yield* reconcileRole(jwtConfigSpec(news));
|
|
102
|
+
return yield* reconcileRole(jwtConfigSpec(news), claimFor({ fqn, instanceId, output }));
|
|
100
103
|
}),
|
|
101
104
|
|
|
102
105
|
/** ⚠️ Writes nothing — there is no delete endpoint (header). */
|
|
103
106
|
delete: () => Effect.void,
|
|
104
107
|
}),
|
|
105
|
-
),
|
|
108
|
+
).pipe(Effect.map(provingResumes)),
|
|
106
109
|
);
|
package/src/openbao/jwt-role.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
23
23
|
import * as Provider from 'alchemy/Provider';
|
|
24
24
|
import * as Effect from 'effect/Effect';
|
|
25
25
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
26
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
27
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
26
28
|
import { baoDelete } from './bao-http.ts';
|
|
27
29
|
import {
|
|
28
30
|
type BaoJwtRoleAttributes,
|
|
@@ -34,7 +36,7 @@ import {
|
|
|
34
36
|
writeBody,
|
|
35
37
|
} from './jwt-role-form.ts';
|
|
36
38
|
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
37
|
-
import { type RoleSpec, planRole,
|
|
39
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
38
40
|
|
|
39
41
|
export type {
|
|
40
42
|
BaoJwtCallbackMode,
|
|
@@ -87,25 +89,26 @@ export const BaoJwtRoleProvider = () =>
|
|
|
87
89
|
*/
|
|
88
90
|
list: () => Effect.succeed([]),
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
92
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
93
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
94
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, jwtRoleSpec(olds));
|
|
93
95
|
}),
|
|
94
96
|
|
|
95
97
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
|
|
96
98
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
97
99
|
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
98
100
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
101
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
99
102
|
if (output === undefined) return undefined;
|
|
100
103
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
101
104
|
if (!isResolved(news)) return undefined;
|
|
102
105
|
return { action: yield* planRole(jwtRoleSpec(news)) } as const;
|
|
103
106
|
}),
|
|
104
107
|
|
|
105
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
108
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
106
109
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
107
110
|
yield* guardRename(IDENTITY, news, output);
|
|
108
|
-
return yield* reconcileRole(jwtRoleSpec(news));
|
|
111
|
+
return yield* reconcileRole(jwtRoleSpec(news), claimFor({ fqn, instanceId, output }));
|
|
109
112
|
}),
|
|
110
113
|
|
|
111
114
|
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
@@ -114,5 +117,5 @@ export const BaoJwtRoleProvider = () =>
|
|
|
114
117
|
return undefined;
|
|
115
118
|
}),
|
|
116
119
|
}),
|
|
117
|
-
),
|
|
120
|
+
).pipe(Effect.map(provingResumes)),
|
|
118
121
|
);
|
|
@@ -17,6 +17,8 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
17
17
|
import * as Provider from 'alchemy/Provider';
|
|
18
18
|
import * as Effect from 'effect/Effect';
|
|
19
19
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
20
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
21
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
20
22
|
import { baoDelete } from './bao-http.ts';
|
|
21
23
|
import {
|
|
22
24
|
type BaoKubernetesRoleAttributes,
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
writeBody,
|
|
29
31
|
} from './kubernetes-role-form.ts';
|
|
30
32
|
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
31
|
-
import { type RoleSpec, planRole,
|
|
33
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
32
34
|
|
|
33
35
|
export type {
|
|
34
36
|
BaoKubernetesAliasSource,
|
|
@@ -75,25 +77,29 @@ export const BaoKubernetesRoleProvider = () =>
|
|
|
75
77
|
/** ⛔ The mount's role listing is not a list of things this owns. */
|
|
76
78
|
list: () => Effect.succeed([]),
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
80
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
81
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
82
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, kubernetesRoleSpec(olds));
|
|
81
83
|
}),
|
|
82
84
|
|
|
83
85
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
|
|
84
86
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
85
87
|
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
86
88
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
89
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
87
90
|
if (output === undefined) return undefined;
|
|
88
91
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
89
92
|
if (!isResolved(news)) return undefined;
|
|
90
93
|
return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
|
|
91
94
|
}),
|
|
92
95
|
|
|
93
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
96
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
94
97
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
95
98
|
yield* guardRename(IDENTITY, news, output);
|
|
96
|
-
return yield* reconcileRole(
|
|
99
|
+
return yield* reconcileRole(
|
|
100
|
+
kubernetesRoleSpec(news),
|
|
101
|
+
claimFor({ fqn, instanceId, output }),
|
|
102
|
+
);
|
|
97
103
|
}),
|
|
98
104
|
|
|
99
105
|
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
@@ -102,5 +108,5 @@ export const BaoKubernetesRoleProvider = () =>
|
|
|
102
108
|
return undefined;
|
|
103
109
|
}),
|
|
104
110
|
}),
|
|
105
|
-
),
|
|
111
|
+
).pipe(Effect.map(provingResumes)),
|
|
106
112
|
);
|
|
@@ -31,6 +31,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
31
31
|
import * as Provider from 'alchemy/Provider';
|
|
32
32
|
import * as Effect from 'effect/Effect';
|
|
33
33
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
34
|
+
import { type Claim, claimFor } from '../ownership/adopt.ts';
|
|
35
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
36
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
34
37
|
import type { BaoError } from './bao-status.ts';
|
|
35
38
|
import {
|
|
36
39
|
type BaoMfaLoginEnforcementAttributes,
|
|
@@ -96,14 +99,21 @@ export const planEnforcement = (
|
|
|
96
99
|
return live !== undefined && matches(live, props, resolved) ? 'noop' : 'update';
|
|
97
100
|
});
|
|
98
101
|
|
|
99
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Write when live differs, then prove it by reading back.
|
|
104
|
+
* ⛔ `claim` (the provider passes it): a create never takes over an enforcement it finds by name.
|
|
105
|
+
*/
|
|
100
106
|
export const reconcileEnforcement = (
|
|
101
107
|
props: BaoMfaLoginEnforcementProps,
|
|
108
|
+
claim?: Claim,
|
|
102
109
|
): Effect.Effect<BaoMfaLoginEnforcementAttributes, BaoError, Env> =>
|
|
103
110
|
Effect.gen(function* () {
|
|
104
111
|
const { bad, resolved } = yield* resolveTargets(props);
|
|
105
112
|
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
106
113
|
const live = yield* readLive(props.name);
|
|
114
|
+
if (live !== undefined && claim !== undefined) {
|
|
115
|
+
yield* claim(`Bao.MfaLoginEnforcement ${props.name}`);
|
|
116
|
+
}
|
|
107
117
|
if (live === undefined || !matches(live, props, resolved)) {
|
|
108
118
|
yield* writeEnforcement(props.name, writeBody(canonicalFromProps(props, resolved)));
|
|
109
119
|
}
|
|
@@ -124,24 +134,28 @@ export const BaoMfaLoginEnforcementProvider = () =>
|
|
|
124
134
|
/** ⛔ The namespace's enforcement listing is not a list of things this owns. */
|
|
125
135
|
list: () => Effect.succeed([]),
|
|
126
136
|
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
138
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
139
|
+
const found = yield* readLive(olds.name);
|
|
140
|
+
const ours = Effect.map(planEnforcement(olds), (plan) => plan === 'noop');
|
|
141
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
129
142
|
}),
|
|
130
143
|
|
|
131
144
|
/** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
|
|
132
145
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
133
146
|
// ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
|
|
134
147
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
148
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
135
149
|
if (output === undefined) return undefined;
|
|
136
150
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
137
151
|
if (!isResolved(news)) return undefined;
|
|
138
152
|
return { action: yield* planEnforcement(news) } as const;
|
|
139
153
|
}),
|
|
140
154
|
|
|
141
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
155
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
142
156
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
143
157
|
yield* guardRename(IDENTITY, news, output);
|
|
144
|
-
return yield* reconcileEnforcement(news);
|
|
158
|
+
return yield* reconcileEnforcement(news, claimFor({ fqn, instanceId, output }));
|
|
145
159
|
}),
|
|
146
160
|
|
|
147
161
|
/** ⛔ REFUSED — read the header: in 2.6.2 the delete does not survive a restart. */
|
|
@@ -154,5 +168,5 @@ export const BaoMfaLoginEnforcementProvider = () =>
|
|
|
154
168
|
);
|
|
155
169
|
}),
|
|
156
170
|
}),
|
|
157
|
-
),
|
|
171
|
+
).pipe(Effect.map(provingResumes)),
|
|
158
172
|
);
|
package/src/openbao/mfa-totp.ts
CHANGED
|
@@ -20,6 +20,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
20
20
|
import * as Provider from 'alchemy/Provider';
|
|
21
21
|
import * as Effect from 'effect/Effect';
|
|
22
22
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
|
+
import { type Claim, claimFor } from '../ownership/adopt.ts';
|
|
24
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
25
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
23
26
|
import type { BaoError } from './bao-status.ts';
|
|
24
27
|
import {
|
|
25
28
|
type BaoMfaTotpMethodAttributes,
|
|
@@ -70,14 +73,19 @@ export const planTotp = (
|
|
|
70
73
|
return live !== undefined && matches(live, props) ? 'noop' : 'update';
|
|
71
74
|
});
|
|
72
75
|
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Upsert by name when live differs, then prove it by reading back.
|
|
78
|
+
* ⛔ `claim` (the provider passes it): a create never takes over a method it finds by that name.
|
|
79
|
+
*/
|
|
74
80
|
export const reconcileTotp = (
|
|
75
81
|
props: BaoMfaTotpMethodProps,
|
|
82
|
+
claim?: Claim,
|
|
76
83
|
): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
|
|
77
84
|
Effect.gen(function* () {
|
|
78
85
|
const bad = problems(props);
|
|
79
86
|
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
80
87
|
const live = yield* readTotp(props);
|
|
88
|
+
if (live !== undefined && claim !== undefined) yield* claim(`Bao.MfaTotpMethod ${props.name}`);
|
|
81
89
|
if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
|
|
82
90
|
const after = yield* readTotp(props);
|
|
83
91
|
if (after === undefined)
|
|
@@ -94,8 +102,13 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
94
102
|
/** ⛔ The namespace's method listing is not a list of things this owns. */
|
|
95
103
|
list: () => Effect.succeed([]),
|
|
96
104
|
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
106
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
107
|
+
const found = yield* readTotp(olds);
|
|
108
|
+
const ours = Effect.sync(
|
|
109
|
+
() => found !== undefined && problems(olds).length === 0 && matches(found, olds),
|
|
110
|
+
);
|
|
111
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
99
112
|
}),
|
|
100
113
|
|
|
101
114
|
/**
|
|
@@ -110,15 +123,17 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
110
123
|
const rename = renameProblem(output.name, name);
|
|
111
124
|
if (rename !== undefined) return yield* refuse(name, rename);
|
|
112
125
|
}
|
|
113
|
-
|
|
126
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
127
|
+
if (output === undefined) return undefined;
|
|
128
|
+
if (!isResolved(news)) return undefined;
|
|
114
129
|
return { action: yield* planTotp(news) } as const;
|
|
115
130
|
}),
|
|
116
131
|
|
|
117
132
|
/** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
|
|
118
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
133
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
119
134
|
const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
|
|
120
135
|
if (rename !== undefined) return yield* refuse(news.name, rename);
|
|
121
|
-
return yield* reconcileTotp(news);
|
|
136
|
+
return yield* reconcileTotp(news, claimFor({ fqn, instanceId, output }));
|
|
122
137
|
}),
|
|
123
138
|
|
|
124
139
|
/** ⛔ Read the header first. Idempotent: an unknown id deletes as success. */
|
|
@@ -127,5 +142,5 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
127
142
|
return undefined;
|
|
128
143
|
}),
|
|
129
144
|
}),
|
|
130
|
-
),
|
|
145
|
+
).pipe(Effect.map(provingResumes)),
|
|
131
146
|
);
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { describe, it } from 'node:test';
|
|
7
7
|
import { reconcileAuthMethod } from './auth-method-reconcile.ts';
|
|
8
|
-
import { type
|
|
8
|
+
import { type Seen, run, withFake } from './fake-bao.ts';
|
|
9
|
+
import { liveTable } from './fake-mounts.ts';
|
|
9
10
|
import { planMove } from './mount-move.ts';
|
|
10
11
|
import { reconcileMount } from './mount-reconcile.ts';
|
|
11
12
|
|
|
@@ -32,40 +33,6 @@ describe('planMove', () => {
|
|
|
32
33
|
});
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
/** A fake with a live table under `prefix` (`sys/mounts` or `sys/auth`) that sys/remount edits. */
|
|
36
|
-
const liveTable = (prefix: string, initial: Record<string, string>) => {
|
|
37
|
-
const table = new Map(Object.entries(initial).map(([path, type]) => [`${path}/`, type]));
|
|
38
|
-
const entry = (type: string) => ({ config: { default_lease_ttl: 0, max_lease_ttl: 0 }, type });
|
|
39
|
-
const answer = (seen: Seen): Reply => {
|
|
40
|
-
const path = seen.path.replace(/^\/v1\//, '');
|
|
41
|
-
if (path === 'sys/remount' && seen.method === 'POST') {
|
|
42
|
-
const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
|
|
43
|
-
const key = `${from.replace(/^auth\//, '')}/`;
|
|
44
|
-
const type = table.get(key);
|
|
45
|
-
if (type === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
|
|
46
|
-
table.delete(key);
|
|
47
|
-
table.set(`${to.replace(/^auth\//, '')}/`, type);
|
|
48
|
-
return { json: { data: { migration_id: 'm-1' } }, status: 200 };
|
|
49
|
-
}
|
|
50
|
-
if (path.startsWith('sys/remount/status/')) {
|
|
51
|
-
return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
|
|
52
|
-
}
|
|
53
|
-
if (path === prefix) {
|
|
54
|
-
return { json: { data: Object.fromEntries(table) }, status: 200 };
|
|
55
|
-
}
|
|
56
|
-
const mount = path.slice(prefix.length + 1).replace(/\/tune$/, '');
|
|
57
|
-
if (seen.method === 'POST') {
|
|
58
|
-
if (!path.endsWith('/tune')) table.set(`${mount}/`, 'enabled-by-test');
|
|
59
|
-
return { status: 204 };
|
|
60
|
-
}
|
|
61
|
-
const type = table.get(`${mount}/`);
|
|
62
|
-
return type === undefined
|
|
63
|
-
? { json: { errors: [`No secret engine mount at ${mount}/`] }, status: 400 }
|
|
64
|
-
: { json: { data: entry(type) }, status: 200 };
|
|
65
|
-
};
|
|
66
|
-
return { answer, table };
|
|
67
|
-
};
|
|
68
|
-
|
|
69
36
|
const posts = (seen: Seen[]) =>
|
|
70
37
|
seen.filter((each) => each.method === 'POST').map((each) => each.path);
|
|
71
38
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as Effect from 'effect/Effect';
|
|
6
6
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
7
|
+
import type { Claim } from '../ownership/adopt.ts';
|
|
7
8
|
import type { BaoError } from './bao-status.ts';
|
|
8
9
|
import {
|
|
9
10
|
type BaoMountAttributes,
|
|
@@ -37,22 +38,31 @@ const moveTarget = (props: BaoMountProps): MoveTarget => ({
|
|
|
37
38
|
/**
|
|
38
39
|
* Converge one mount. `stated` is the path in Alchemy's state (undefined on a first reconcile).
|
|
39
40
|
* Dies on a refusal; fails with OpenBao's own error on a refused call.
|
|
41
|
+
* ⛔ `claim` (mount.ts always passes it) refuses, before any write, a first reconcile that would take
|
|
42
|
+
* over a live mount: the one at the path, or the `remountFrom` source it would move
|
|
43
|
+
* (ownership/adopt.ts).
|
|
40
44
|
*/
|
|
41
45
|
export const reconcileMount = (
|
|
42
46
|
news: BaoMountProps,
|
|
43
47
|
stated: string | undefined,
|
|
44
48
|
options?: RemountOptions,
|
|
49
|
+
claim?: Claim,
|
|
45
50
|
): Effect.Effect<BaoMountAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
46
51
|
Effect.gen(function* () {
|
|
47
52
|
const move = planMove('Bao.Mount', stated, news.path, news.remountFrom);
|
|
48
53
|
if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
|
|
54
|
+
const first = stated === undefined ? claim : undefined;
|
|
55
|
+
if (first !== undefined && move.kind === 'move') {
|
|
56
|
+
const source = yield* readMount({ ...news, path: move.from });
|
|
57
|
+
if (source !== undefined) yield* first(`Bao.Mount ${move.from} (the remountFrom source)`);
|
|
58
|
+
}
|
|
49
59
|
if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
|
|
50
60
|
|
|
51
61
|
let live = yield* readMount(news);
|
|
52
62
|
if (live === undefined) {
|
|
53
63
|
yield* enableMount(news);
|
|
54
64
|
if (wantsTune(news)) yield* tuneMount(news);
|
|
55
|
-
} else
|
|
65
|
+
} else {
|
|
56
66
|
if (live.type !== news.type) {
|
|
57
67
|
return yield* Effect.die(
|
|
58
68
|
new Error(
|
|
@@ -61,7 +71,8 @@ export const reconcileMount = (
|
|
|
61
71
|
),
|
|
62
72
|
);
|
|
63
73
|
}
|
|
64
|
-
yield*
|
|
74
|
+
if (first !== undefined) yield* first(`Bao.Mount ${mountPath(news.path)}`);
|
|
75
|
+
if (!matches(live, news)) yield* tuneMount(news);
|
|
65
76
|
}
|
|
66
77
|
live = yield* readMount(news);
|
|
67
78
|
if (live === undefined) {
|
package/src/openbao/mount.ts
CHANGED
|
@@ -22,6 +22,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
22
22
|
import * as Provider from 'alchemy/Provider';
|
|
23
23
|
import * as Effect from 'effect/Effect';
|
|
24
24
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
25
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
26
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
27
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
25
28
|
import { type BaoMountAttributes, type BaoMountProps, matches } from './mount-form.ts';
|
|
26
29
|
import { planMove } from './mount-move.ts';
|
|
27
30
|
import { readMount, reconcileMount } from './mount-reconcile.ts';
|
|
@@ -55,12 +58,17 @@ export const BaoMountProvider = () =>
|
|
|
55
58
|
*/
|
|
56
59
|
list: () => Effect.succeed([]),
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
62
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
63
|
+
const found = yield* readMount(olds);
|
|
64
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
65
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
60
66
|
}),
|
|
61
67
|
|
|
62
68
|
diff: Effect.fn(function* ({ news, output }) {
|
|
63
|
-
|
|
69
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
70
|
+
if (output === undefined) return undefined;
|
|
71
|
+
if (!isResolved(news)) return undefined;
|
|
64
72
|
/**
|
|
65
73
|
* ⛔ A CHANGED PATH IS DECIDED HERE, BEFORE ANY READ OF THE NEW PATH — reading it would
|
|
66
74
|
* find nothing and plan `update`, which is how an empty mount used to get enabled.
|
|
@@ -98,8 +106,9 @@ export const BaoMountProvider = () =>
|
|
|
98
106
|
* method and path — the job the per-command exit-code checks used to do by hand.
|
|
99
107
|
* The body, and the move, live in mount-reconcile.ts.
|
|
100
108
|
*/
|
|
101
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
102
|
-
|
|
109
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
110
|
+
const claim = claimFor({ fqn, instanceId, output });
|
|
111
|
+
return yield* reconcileMount(news, output?.path, undefined, claim);
|
|
103
112
|
}),
|
|
104
113
|
|
|
105
114
|
/**
|
|
@@ -111,5 +120,5 @@ export const BaoMountProvider = () =>
|
|
|
111
120
|
return undefined;
|
|
112
121
|
}),
|
|
113
122
|
}),
|
|
114
|
-
),
|
|
123
|
+
).pipe(Effect.map(provingResumes)),
|
|
115
124
|
);
|