@homeflare/alchemy 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -41
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +51 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +16 -12
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters
|
|
3
|
-
* a write parses the duration text into seconds and re-marshals `policies`
|
|
4
|
-
* :199-210), a role never written is a 404, and a delete answers 204
|
|
2
|
+
* Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters
|
|
3
|
+
* (fake-engines.ts): a write parses the duration text into seconds and re-marshals `policies`
|
|
4
|
+
* (path_roles.go:117-146, :199-210), a role never written is a 404, and a delete answers 204
|
|
5
|
+
* whether or not it existed.
|
|
5
6
|
*/
|
|
6
7
|
import assert from 'node:assert/strict';
|
|
7
8
|
import { describe, it } from 'node:test';
|
|
@@ -13,8 +14,8 @@ import {
|
|
|
13
14
|
readCloudflareRole,
|
|
14
15
|
writeCloudflareRole,
|
|
15
16
|
} from './cloudflare-role-wire.ts';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { run, runFailure, withFake } from './fake-bao.ts';
|
|
18
|
+
import { cloudflareRoles } from './fake-engines.ts';
|
|
18
19
|
|
|
19
20
|
const PROPS: BaoCloudflareRoleProps = {
|
|
20
21
|
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
@@ -34,36 +35,9 @@ const PROPS: BaoCloudflareRoleProps = {
|
|
|
34
35
|
|
|
35
36
|
const PATH = '/v1/cloudflare-acme-dns/roles/example-com-dns-read';
|
|
36
37
|
|
|
37
|
-
/** The engine's role store, reduced to what these calls can observe. */
|
|
38
|
-
const roleStore = () => {
|
|
39
|
-
const roles = new Map<string, Record<string, unknown>>();
|
|
40
|
-
return (seen: Seen): Reply => {
|
|
41
|
-
if (seen.method === 'PUT') {
|
|
42
|
-
const body = JSON.parse(seen.body) as Record<string, string>;
|
|
43
|
-
roles.set(seen.path, {
|
|
44
|
-
description: body['description'],
|
|
45
|
-
max_ttl: parseDuration(body['max_ttl'] ?? ''),
|
|
46
|
-
name: seen.path.split('/').at(-1),
|
|
47
|
-
// ★ Re-marshalled, as json.Marshal of the stored structs would be.
|
|
48
|
-
policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
|
|
49
|
-
ttl: parseDuration(body['ttl'] ?? ''),
|
|
50
|
-
});
|
|
51
|
-
return { status: 204 };
|
|
52
|
-
}
|
|
53
|
-
if (seen.method === 'DELETE') {
|
|
54
|
-
roles.delete(seen.path);
|
|
55
|
-
return { status: 204 };
|
|
56
|
-
}
|
|
57
|
-
const role = roles.get(seen.path);
|
|
58
|
-
return role === undefined
|
|
59
|
-
? { json: { errors: [] }, status: 404 }
|
|
60
|
-
: { json: { data: role }, status: 200 };
|
|
61
|
-
};
|
|
62
|
-
};
|
|
63
|
-
|
|
64
38
|
describe('Cloudflare role wire', () => {
|
|
65
39
|
it('reads a role that was never written as absent', async () => {
|
|
66
|
-
await withFake(
|
|
40
|
+
await withFake(cloudflareRoles(), async (bao) => {
|
|
67
41
|
const env = { BAO_ADDR: bao.address };
|
|
68
42
|
assert.equal(await run(env, readCloudflareRole(PROPS.mount, PROPS.name)), undefined);
|
|
69
43
|
assert.deepEqual([bao.seen[0]?.method, bao.seen[0]?.path], ['GET', PATH]);
|
|
@@ -71,7 +45,7 @@ describe('Cloudflare role wire', () => {
|
|
|
71
45
|
});
|
|
72
46
|
|
|
73
47
|
it('writes the four fields and reads them back with no difference', async () => {
|
|
74
|
-
await withFake(
|
|
48
|
+
await withFake(cloudflareRoles(), async (bao) => {
|
|
75
49
|
const env = { BAO_ADDR: bao.address };
|
|
76
50
|
const { policies } = resolvePolicies(
|
|
77
51
|
PROPS.policies,
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
readCloudflareRole,
|
|
39
39
|
writeCloudflareRole,
|
|
40
40
|
} from './cloudflare-role-wire.ts';
|
|
41
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
41
42
|
|
|
42
43
|
export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
|
|
43
44
|
|
|
@@ -59,6 +60,9 @@ export const BaoCloudflareRole = Resource<BaoCloudflareRole>('Bao.CloudflareRole
|
|
|
59
60
|
defaultRemovalPolicy: 'retain',
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
/** Exact: a trailing `/` on the mount is the same path (`rolePath` trims it). */
|
|
64
|
+
const IDENTITY = roleIdentity<BaoCloudflareRoleAttributes>('Bao.CloudflareRole', rolePath);
|
|
65
|
+
|
|
62
66
|
export const BaoCloudflareRoleProvider = () =>
|
|
63
67
|
Provider.effect(
|
|
64
68
|
BaoCloudflareRole,
|
|
@@ -112,14 +116,22 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
112
116
|
* OpenBao UI changes what every future token can do while the digest in Postgres still
|
|
113
117
|
* says everything is fine.
|
|
114
118
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* the
|
|
118
|
-
*
|
|
119
|
-
*
|
|
119
|
+
* ⛔ A NEW `mount` OR `name` IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
|
|
120
|
+
* 2026-09-21 this read the new path, found it absent and planned `update`: reconcile wrote
|
|
121
|
+
* the new role and the old one stayed live and mintable under no state record. ⛔ A move
|
|
122
|
+
* onto a role that already exists fails the plan (`judgeMove`).
|
|
123
|
+
* ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL MINTS for any token whose policy reaches its
|
|
124
|
+
* `creds/<name>`. Under `destroy` every consumer still minting from the old path fails at
|
|
125
|
+
* the delete, so move them in the same PR (REPLACE.md).
|
|
126
|
+
* ★ A STACK THAT PUTS mount AND name IN THE LOGICAL ID never reaches this branch
|
|
127
|
+
* (homeflare-openbao's declareCloudflareRoles does). There a rename is a new logical id,
|
|
128
|
+
* and the old id leaves the stack as an orphan delete, which `retain` keeps live.
|
|
120
129
|
*/
|
|
121
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
122
|
-
|
|
130
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
131
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
132
|
+
if (output === undefined) return undefined;
|
|
133
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
134
|
+
if (!isResolved(news)) return undefined;
|
|
123
135
|
/**
|
|
124
136
|
* ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop — route it to reconcile,
|
|
125
137
|
* which says why (pki-role.ts has the same guard).
|
|
@@ -133,8 +145,10 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
133
145
|
: ({ action: 'update' } as const);
|
|
134
146
|
}),
|
|
135
147
|
|
|
136
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
148
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
137
149
|
const path = rolePath(news.mount, news.name);
|
|
150
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
151
|
+
yield* guardRename(IDENTITY, news, output);
|
|
138
152
|
const refusal = refusalOf(news);
|
|
139
153
|
if (refusal !== undefined) {
|
|
140
154
|
return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fakes of the stores behind the batch-2 families, for fake-bao: every role engine, JWT auth config
|
|
3
|
+
* and login-MFA enforcements (`wireRoles`), the TOTP method table (`totpMethods`) and the plugin
|
|
4
|
+
* catalog (`pluginCatalog`), behind one answer (`fakeEstate`). As in fake-engines.ts, each keys its
|
|
5
|
+
* objects the way the real server does, because a rename test is only as good as the fake's idea of
|
|
6
|
+
* "the same object".
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
9
|
+
*/
|
|
10
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
11
|
+
import { ABSENT, type Store, type Stored, lastSegment, store } from './fake-engines.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The role stores whose server lowercases the name: AppRole (openbao v2.6.2
|
|
15
|
+
* builtin/credential/approle/path_role.go:1485 and :1893), Kubernetes
|
|
16
|
+
* (builtin/credential/kubernetes backend.go:362, path_role.go:250 and :396) and JWT, whose `name`
|
|
17
|
+
* field is a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77; sdk v2.6.2
|
|
18
|
+
* framework/field_data.go:250-255 lowercases it on read). PKI and SSH store the name verbatim. The
|
|
19
|
+
* tests mount all three at their default paths.
|
|
20
|
+
*/
|
|
21
|
+
const FOLDED = /^auth\/(approle|jwt|kubernetes)\/role\/[^/]+$/;
|
|
22
|
+
|
|
23
|
+
const keyOf = (path: string): string =>
|
|
24
|
+
FOLDED.test(path) ? path.replace(/[^/]+$/, (name) => name.toLowerCase()) : path;
|
|
25
|
+
|
|
26
|
+
/** Fields a write sends comma-joined that the server reads back as a list. */
|
|
27
|
+
const LISTS = new Set([
|
|
28
|
+
'allowed_domains',
|
|
29
|
+
'cn_validations',
|
|
30
|
+
'ext_key_usage',
|
|
31
|
+
'key_usage',
|
|
32
|
+
'token_policies',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The server's typed view of a `k=v` body: `true`, `false` and digit strings become values, and the
|
|
37
|
+
* LISTS split. Arrays, objects and duration strings pass through, and every reader here takes a
|
|
38
|
+
* duration string as well as seconds (`ttlSeconds`).
|
|
39
|
+
*/
|
|
40
|
+
const typed = (body: Stored): Stored => {
|
|
41
|
+
const out: Stored = {};
|
|
42
|
+
for (const [field, value] of Object.entries(body)) {
|
|
43
|
+
if (typeof value !== 'string') out[field] = value;
|
|
44
|
+
else if (LISTS.has(field)) out[field] = value.split(',').filter((part) => part !== '');
|
|
45
|
+
else if (value === 'true' || value === 'false') out[field] = value === 'true';
|
|
46
|
+
else out[field] = /^\d+$/.test(value) ? Number(value) : value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Every role engine, JWT auth config and MFA login enforcement. A write replaces the object. */
|
|
52
|
+
export const wireRoles = (): Store =>
|
|
53
|
+
store<Stored>(
|
|
54
|
+
(_path, body) => typed(body),
|
|
55
|
+
(path) => keyOf(path),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `sys/plugins/catalog/<type>/<name>`. A register names its version in the body; a read or a delete
|
|
60
|
+
* names it in `?version=` (plugin-form.ts versionedPath).
|
|
61
|
+
*/
|
|
62
|
+
export const pluginCatalog = (): Store =>
|
|
63
|
+
store<Stored>(
|
|
64
|
+
(path, body) => ({ ...body, builtin: false, name: lastSegment(path.split('?')[0] ?? '') }),
|
|
65
|
+
(path, body) => {
|
|
66
|
+
const version = body?.['version'];
|
|
67
|
+
return typeof version === 'string' && version !== '' ? `${path}?version=${version}` : path;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `identity/mfa/method/totp`: an upsert by `method_name` (identity/mfa.go:159-230), listed with
|
|
73
|
+
* every other type under `identity/mfa/method?list=true` (mfa-wire.ts). Keyed by the server-made
|
|
74
|
+
* id.
|
|
75
|
+
*/
|
|
76
|
+
export const totpMethods = (): Store => {
|
|
77
|
+
const live = new Map<string, Stored>();
|
|
78
|
+
let made = 0;
|
|
79
|
+
const answer = (seen: Seen): Reply => {
|
|
80
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
81
|
+
if (seen.method === 'POST') {
|
|
82
|
+
const body = JSON.parse(seen.body) as Stored;
|
|
83
|
+
const name = body['method_name'];
|
|
84
|
+
const found = [...live].find(([, method]) => method['name'] === name)?.[0];
|
|
85
|
+
made += found === undefined ? 1 : 0;
|
|
86
|
+
const id = found ?? `method-${String(made)}`;
|
|
87
|
+
live.set(id, { ...body, id, name, namespace_path: '', type: 'totp' });
|
|
88
|
+
return { json: { data: { method_id: id } }, status: 200 };
|
|
89
|
+
}
|
|
90
|
+
if (seen.method === 'DELETE') {
|
|
91
|
+
live.delete(lastSegment(path));
|
|
92
|
+
return { status: 204 };
|
|
93
|
+
}
|
|
94
|
+
if (path !== 'identity/mfa/method?list=true' || live.size === 0) return ABSENT;
|
|
95
|
+
const info = Object.fromEntries(live);
|
|
96
|
+
return { json: { data: { key_info: info, keys: [...live.keys()] } }, status: 200 };
|
|
97
|
+
};
|
|
98
|
+
return Object.assign(answer, { live });
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** All three behind one answer, routed by path, for a stack that mixes the families. */
|
|
102
|
+
export const fakeEstate = () => {
|
|
103
|
+
const roles = wireRoles();
|
|
104
|
+
const plugins = pluginCatalog();
|
|
105
|
+
const totp = totpMethods();
|
|
106
|
+
const answer = (seen: Seen): Reply => {
|
|
107
|
+
if (seen.path.startsWith('/v1/identity/mfa/method')) return totp(seen);
|
|
108
|
+
if (seen.path.startsWith('/v1/sys/plugins/catalog/')) return plugins(seen);
|
|
109
|
+
return roles(seen);
|
|
110
|
+
};
|
|
111
|
+
return Object.assign(answer, { plugins, roles, totp });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type Estate = ReturnType<typeof fakeEstate>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
|
|
3
|
+
* plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
|
|
4
|
+
* real server does. That is the point: a rename test is only as good as the fake's idea of "the
|
|
5
|
+
* same object". `store` is shared with fake-engines-roles.ts, which fakes the batch-2 families.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
8
|
+
*/
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as FileSystem from 'effect/FileSystem';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as Path from 'effect/Path';
|
|
13
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
14
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
15
|
+
import { parseDuration } from './mount-form.ts';
|
|
16
|
+
import { trimTrailing } from './mount-path.ts';
|
|
17
|
+
import { BaoPolicyProvider } from './policy.ts';
|
|
18
|
+
import { policyKey } from './rename.ts';
|
|
19
|
+
|
|
20
|
+
export type Stored = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
/** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
|
|
23
|
+
export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
|
|
24
|
+
|
|
25
|
+
export const ABSENT: Reply = { json: { errors: [] }, status: 404 };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A store of objects by API path. `key` turns a request path (and a write's body) into the key the
|
|
29
|
+
* server would store it under.
|
|
30
|
+
*/
|
|
31
|
+
export const store = <B extends Stored = Record<string, string>>(
|
|
32
|
+
write: (path: string, body: B, before: Stored | undefined) => Stored,
|
|
33
|
+
key: (path: string, body?: B) => string = (path) => path,
|
|
34
|
+
): Store => {
|
|
35
|
+
const live = new Map<string, Stored>();
|
|
36
|
+
const answer = (seen: Seen): Reply => {
|
|
37
|
+
const raw = seen.path.replace(/^\/v1\//, '');
|
|
38
|
+
// ★ `bao write` sends PUT, and the identity endpoints are called with POST. Both write.
|
|
39
|
+
if (seen.method === 'PUT' || seen.method === 'POST') {
|
|
40
|
+
const body = JSON.parse(seen.body) as B;
|
|
41
|
+
const path = key(raw, body);
|
|
42
|
+
live.set(path, write(path, body, live.get(path)));
|
|
43
|
+
return { status: 204 };
|
|
44
|
+
}
|
|
45
|
+
const path = key(raw);
|
|
46
|
+
// ★ A delete answers 204 whether or not the object existed, as every engine here does.
|
|
47
|
+
if (seen.method === 'DELETE') {
|
|
48
|
+
live.delete(path);
|
|
49
|
+
return { status: 204 };
|
|
50
|
+
}
|
|
51
|
+
const data = live.get(path);
|
|
52
|
+
return data === undefined ? ABSENT : { json: { data }, status: 200 };
|
|
53
|
+
};
|
|
54
|
+
return Object.assign(answer, { live });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const lastSegment = (path: string): string => path.split('/').at(-1) ?? '';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
|
|
61
|
+
* server — digest.ts), and keys the name as OpenBao does: trimmed and lowercased (`policyKey`).
|
|
62
|
+
*/
|
|
63
|
+
export const aclPolicies = (): Store =>
|
|
64
|
+
store(
|
|
65
|
+
(path, body) => ({
|
|
66
|
+
name: lastSegment(path),
|
|
67
|
+
policy: trimTrailing(body['policy'] ?? '', '\n'),
|
|
68
|
+
}),
|
|
69
|
+
(path) =>
|
|
70
|
+
path.startsWith('sys/policies/acl/')
|
|
71
|
+
? `sys/policies/acl/${policyKey(decodeURIComponent(lastSegment(path)))}`
|
|
72
|
+
: path,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `<mount>/roles/<name>` on the Cloudflare engine. A write parses the duration text into seconds and
|
|
77
|
+
* re-marshals `policies` (path_roles.go:117-146, :199-210). Names are stored verbatim.
|
|
78
|
+
*/
|
|
79
|
+
export const cloudflareRoles = (): Store =>
|
|
80
|
+
store((path, body) => ({
|
|
81
|
+
description: body['description'],
|
|
82
|
+
max_ttl: parseDuration(body['max_ttl'] ?? ''),
|
|
83
|
+
name: lastSegment(path),
|
|
84
|
+
// ★ Re-marshalled, as json.Marshal of the stored structs would be.
|
|
85
|
+
policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
|
|
86
|
+
ttl: parseDuration(body['ttl'] ?? ''),
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `<mount>/roles/<name>` on the proxmox engine. A write MERGES into the stored role, and TTLs go over
|
|
91
|
+
* as seconds (homeflare-openbao-plugins secrets/proxmox/path_roles.go, pathRolesWrite).
|
|
92
|
+
*/
|
|
93
|
+
export const proxmoxRoles = (): Store =>
|
|
94
|
+
store((path, body, before) => ({
|
|
95
|
+
max_ttl: 0,
|
|
96
|
+
name: lastSegment(path),
|
|
97
|
+
ttl: 0,
|
|
98
|
+
...before,
|
|
99
|
+
...(body['mint_user'] === undefined ? {} : { mint_user: body['mint_user'] }),
|
|
100
|
+
...(body['ttl'] === undefined ? {} : { ttl: Number(body['ttl']) }),
|
|
101
|
+
...(body['max_ttl'] === undefined ? {} : { max_ttl: Number(body['max_ttl']) }),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bao.Policy's provider over fragment directories that exist only here: `…/empty` holds no file,
|
|
106
|
+
* `…/two` holds two, and any other directory one. Each file is one grant named after its own path,
|
|
107
|
+
* so a directory's content is its name.
|
|
108
|
+
*/
|
|
109
|
+
export const fakePolicyProviders = Layer.mergeAll(
|
|
110
|
+
BaoPolicyProvider().pipe(
|
|
111
|
+
Layer.provide(
|
|
112
|
+
Layer.mergeAll(
|
|
113
|
+
FileSystem.layerNoop({
|
|
114
|
+
readDirectory: (dir) =>
|
|
115
|
+
Effect.succeed(
|
|
116
|
+
dir.endsWith('/empty') ? [] : dir.endsWith('/two') ? ['a.hcl', 'b.hcl'] : ['a.hcl'],
|
|
117
|
+
),
|
|
118
|
+
readFileString: (path) =>
|
|
119
|
+
Effect.succeed(`path "${path}" {\n capabilities = ["read"]\n}`),
|
|
120
|
+
}),
|
|
121
|
+
Path.layer,
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
FetchHttpClient.layer,
|
|
126
|
+
);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nine batch-2 families as rows of one table, so a rename test can run each of them through
|
|
3
|
+
* Alchemy's own plan and apply (fake-stack.ts) against one fake (fake-engines-roles.ts). Every row
|
|
4
|
+
* declares a resource whose identity is `name` (for Bao.JwtAuthConfig, the mount), with `knob`, a
|
|
5
|
+
* duration, in a prop that is NOT its identity: the pending Output in "a rename lands with another
|
|
6
|
+
* pending prop".
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
11
|
+
import type * as Output from 'alchemy/Output';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
|
+
import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
16
|
+
import type { Seen } from './fake-bao.ts';
|
|
17
|
+
import { type Estate, fakeEstate } from './fake-engines-roles.ts';
|
|
18
|
+
import { type FakeStack, type StackBody, withFakeStack } from './fake-stack.ts';
|
|
19
|
+
import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
|
|
20
|
+
import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
|
|
21
|
+
import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
|
|
22
|
+
import { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
|
|
23
|
+
import { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
|
|
24
|
+
import { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
|
|
25
|
+
import { BaoPlugin, BaoPluginProvider } from './plugin.ts';
|
|
26
|
+
import { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
|
|
27
|
+
|
|
28
|
+
export const familyProviders = Layer.mergeAll(
|
|
29
|
+
BaoAuthRoleProvider(),
|
|
30
|
+
BaoJwtAuthConfigProvider(),
|
|
31
|
+
BaoJwtRoleProvider(),
|
|
32
|
+
BaoKubernetesRoleProvider(),
|
|
33
|
+
BaoMfaLoginEnforcementProvider(),
|
|
34
|
+
BaoMfaTotpMethodProvider(),
|
|
35
|
+
BaoPkiRoleProvider(),
|
|
36
|
+
BaoPluginProvider(),
|
|
37
|
+
BaoSshRoleProvider(),
|
|
38
|
+
FetchHttpClient.layer,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** One stack over a fresh estate: every family's provider, and every call the fake saw. */
|
|
42
|
+
export const withEstate = (
|
|
43
|
+
body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
|
|
44
|
+
): Promise<void> => {
|
|
45
|
+
const estate = fakeEstate();
|
|
46
|
+
return withFakeStack(familyProviders, estate, (stack, bao) => body(stack, estate, bao.seen));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The row for one family, by name. */
|
|
50
|
+
export const rowOf = (family: string): Family => {
|
|
51
|
+
const row = FAMILIES.find((each) => each.family === family);
|
|
52
|
+
if (row === undefined) throw new Error(`no fake-families row for ${family}`);
|
|
53
|
+
return row;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type Text = string | Output.Output<string>;
|
|
57
|
+
type Removal = typeof RemovalPolicy.destroy;
|
|
58
|
+
|
|
59
|
+
export interface Family {
|
|
60
|
+
readonly family: string;
|
|
61
|
+
/** One resource, logical id `id`, whose identity is `name`. */
|
|
62
|
+
readonly declare: (id: string, name: Text, knob: Text, removal?: Removal) => StackBody;
|
|
63
|
+
/** Whether the fake holds the object `name` names. */
|
|
64
|
+
readonly has: (estate: Estate, name: string) => boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const role = (path: (name: string) => string) => (estate: Estate, name: string) =>
|
|
68
|
+
estate.roles.live.has(path(name));
|
|
69
|
+
|
|
70
|
+
const APPROLE = { secretIdTtl: '24h', tokenMaxTtl: '1h', tokenPolicies: ['default'] } as const;
|
|
71
|
+
const SHA = 'a'.repeat(64);
|
|
72
|
+
|
|
73
|
+
export const FAMILIES: readonly Family[] = [
|
|
74
|
+
{
|
|
75
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
76
|
+
BaoAuthRole(id, { ...APPROLE, name, tokenTtl: knob }).pipe(removal()),
|
|
77
|
+
family: 'Bao.AuthRole',
|
|
78
|
+
has: role((name) => `auth/approle/role/${name}`),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
82
|
+
BaoPkiRole(id, { allowedDomains: ['example.com'], maxTtl: '8760h', name, ttl: knob }).pipe(
|
|
83
|
+
removal(),
|
|
84
|
+
),
|
|
85
|
+
family: 'Bao.PkiRole',
|
|
86
|
+
has: role((name) => `pki/roles/${name}`),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
90
|
+
BaoJwtRole(id, {
|
|
91
|
+
boundAudiences: ['https://example.com'],
|
|
92
|
+
name,
|
|
93
|
+
roleType: 'jwt',
|
|
94
|
+
tokenPolicies: ['ci'],
|
|
95
|
+
tokenTtl: knob,
|
|
96
|
+
userClaim: 'sub',
|
|
97
|
+
}).pipe(removal()),
|
|
98
|
+
family: 'Bao.JwtRole',
|
|
99
|
+
has: role((name) => `auth/jwt/role/${name}`),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
103
|
+
BaoKubernetesRole(id, {
|
|
104
|
+
aliasNameSource: 'serviceaccount_uid',
|
|
105
|
+
boundServiceAccountNames: ['app'],
|
|
106
|
+
boundServiceAccountNamespaces: ['apps'],
|
|
107
|
+
name,
|
|
108
|
+
tokenPolicies: ['app'],
|
|
109
|
+
tokenTtl: knob,
|
|
110
|
+
}).pipe(removal()),
|
|
111
|
+
family: 'Bao.KubernetesRole',
|
|
112
|
+
has: role((name) => `auth/kubernetes/role/${name}`),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
declare: (id, mount, knob, removal = RemovalPolicy.retain) =>
|
|
116
|
+
BaoJwtAuthConfig(id, {
|
|
117
|
+
boundIssuer: knob,
|
|
118
|
+
jwksUrl: 'https://issuer.example.com/keys',
|
|
119
|
+
mount,
|
|
120
|
+
}).pipe(removal()),
|
|
121
|
+
family: 'Bao.JwtAuthConfig',
|
|
122
|
+
has: role((mount) => `auth/${mount}/config`),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
126
|
+
BaoMfaTotpMethod(id, { issuer: knob, name }).pipe(removal()),
|
|
127
|
+
family: 'Bao.MfaTotpMethod',
|
|
128
|
+
has: (estate, name) => [...estate.totp.live.values()].some((each) => each['name'] === name),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
132
|
+
BaoMfaLoginEnforcement(id, {
|
|
133
|
+
authMethodTypes: ['jwt'],
|
|
134
|
+
mfaMethodIds: [knob],
|
|
135
|
+
name,
|
|
136
|
+
}).pipe(removal()),
|
|
137
|
+
family: 'Bao.MfaLoginEnforcement',
|
|
138
|
+
has: role((name) => `identity/mfa/login-enforcement/${name}`),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
142
|
+
BaoSshRole(id, {
|
|
143
|
+
allowUserCertificates: true,
|
|
144
|
+
allowedUsers: ['ops'],
|
|
145
|
+
maxTtl: '8h',
|
|
146
|
+
name,
|
|
147
|
+
ttl: knob,
|
|
148
|
+
}).pipe(removal()),
|
|
149
|
+
family: 'Bao.SshRole',
|
|
150
|
+
has: role((name) => `ssh/roles/${name}`),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
154
|
+
BaoPlugin(id, {
|
|
155
|
+
args: [knob],
|
|
156
|
+
command: 'plugin-example',
|
|
157
|
+
name,
|
|
158
|
+
sha256: SHA,
|
|
159
|
+
type: 'secret',
|
|
160
|
+
version: 'v1.0.0',
|
|
161
|
+
}).pipe(removal()),
|
|
162
|
+
family: 'Bao.Plugin',
|
|
163
|
+
has: (estate, name) =>
|
|
164
|
+
estate.plugins.live.has(`sys/plugins/catalog/secret/${name}?version=v1.0.0`),
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Two of one family, `X` and `Y`, under destroy: the shape a swap or a shift needs. Their knobs
|
|
170
|
+
* differ, so a generation that lands on the other's name has to write over it.
|
|
171
|
+
*/
|
|
172
|
+
export const pairOf =
|
|
173
|
+
(row: Family) =>
|
|
174
|
+
(x: string, y: string): StackBody =>
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
yield* row.declare('X', x, '15m', RemovalPolicy.destroy);
|
|
177
|
+
yield* row.declare('Y', y, '30m', RemovalPolicy.destroy);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** An AppRole whose `tokenTtl` attribute is the Output the other rows are fed. */
|
|
181
|
+
export const upstream = (ttl: string) =>
|
|
182
|
+
BaoAuthRole('Upstream', { ...APPROLE, name: 'upstream', tokenTtl: ttl });
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alchemy's own Plan and Apply, run in-process over an in-memory state store, so a test can deploy a
|
|
3
|
+
* declaration, change it, deploy again, and see what the ENGINE did with a provider's answers: which
|
|
4
|
+
* action it planned, and which calls reached the fake.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THE ENGINE AND NOT THE HANDLERS ALONE. A diff that answers `replace` proves nothing by itself.
|
|
7
|
+
* What matters is that Apply then creates the new generation, deletes the OLD one with the OLD
|
|
8
|
+
* attributes, and keeps it under `retain`. Those are claims about alchemy@2.0.0-beta.79, and this
|
|
9
|
+
* harness measures them instead of restating them (REPLACE.md).
|
|
10
|
+
* ★ NOT alchemy/Test/Bun. Its runtime builds the CLI's profile and credentials stores, which read
|
|
11
|
+
* files under the home directory, plus a file logger. This needs none of that: the providers, an
|
|
12
|
+
* in-memory state store, a stage, and the stack's own services. It is the composition
|
|
13
|
+
* alchemy/src/Deploy.ts uses, minus the platform layer.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ TEST-ONLY, AND NEVER A REAL VAULT. `BaoEnv` is provided explicitly, so a BAO_ADDR or BAO_TOKEN
|
|
16
|
+
* in the shell running the tests is never read. No provider imports this file.
|
|
17
|
+
*/
|
|
18
|
+
import * as Alchemy from 'alchemy';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import * as Layer from 'effect/Layer';
|
|
21
|
+
import type { BaoEnvironment } from './bao-address.ts';
|
|
22
|
+
import { BaoEnv } from './bao-http.ts';
|
|
23
|
+
import { type Fake, type Reply, type Seen, fakeBao } from './fake-bao.ts';
|
|
24
|
+
|
|
25
|
+
/** The planned action for each resource, deletions included, keyed by FQN. */
|
|
26
|
+
export type Planned = Readonly<Record<string, string>>;
|
|
27
|
+
|
|
28
|
+
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
|
29
|
+
export type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
30
|
+
|
|
31
|
+
export interface FakeStack {
|
|
32
|
+
/** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
|
|
33
|
+
readonly deploy: (body: StackBody) => Promise<Planned>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Node = { readonly action: string };
|
|
37
|
+
type PlanView = {
|
|
38
|
+
readonly resources: Readonly<Record<string, Node>>;
|
|
39
|
+
readonly deletions: Readonly<Record<string, Node | undefined>>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const actionsOf = (plan: PlanView): Planned => {
|
|
43
|
+
const out: Record<string, string> = {};
|
|
44
|
+
for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
|
|
45
|
+
for (const [fqn, node] of Object.entries(plan.deletions)) {
|
|
46
|
+
if (node !== undefined) out[fqn] = node.action;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One stack over one state store, shared by every `deploy`, so the second deploy plans against what
|
|
53
|
+
* the first one recorded.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY, NOT IN THE PROVIDERS. `Alchemy.Stack` types its body
|
|
56
|
+
* against every service a CLI run provides. Here the body needs only the providers given, and a
|
|
57
|
+
* missing one fails the test by name ("Provider not found for …").
|
|
58
|
+
*/
|
|
59
|
+
export const fakeStack = <ROut, E, RIn>(
|
|
60
|
+
providers: Layer.Layer<ROut, E, RIn>,
|
|
61
|
+
env: BaoEnvironment,
|
|
62
|
+
name = 'RenameStack',
|
|
63
|
+
): FakeStack => {
|
|
64
|
+
const state = Alchemy.inMemoryState();
|
|
65
|
+
const stack = Alchemy.Stack as unknown as (
|
|
66
|
+
stackName: string,
|
|
67
|
+
options: { providers: typeof providers; state: typeof state },
|
|
68
|
+
body: StackBody,
|
|
69
|
+
) => Effect.Effect<{ readonly services: never }, unknown, never>;
|
|
70
|
+
const plan = Alchemy.Plan.make as unknown as (
|
|
71
|
+
compiled: unknown,
|
|
72
|
+
) => Effect.Effect<PlanView, unknown, never>;
|
|
73
|
+
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
74
|
+
|
|
75
|
+
const deploy = (body: StackBody) =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const compiled = yield* stack(name, { providers, state }, body);
|
|
78
|
+
return yield* Effect.gen(function* () {
|
|
79
|
+
const planned = yield* plan(compiled);
|
|
80
|
+
yield* apply(planned);
|
|
81
|
+
return actionsOf(planned);
|
|
82
|
+
}).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
|
|
83
|
+
}).pipe(
|
|
84
|
+
Effect.provideService(Alchemy.Stage, 'test'),
|
|
85
|
+
Effect.provideService(BaoEnv, env),
|
|
86
|
+
Effect.provide(state),
|
|
87
|
+
Effect.scoped,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { deploy: (body) => Effect.runPromise(deploy(body) as Effect.Effect<Planned>) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A fake engine answering with `answer`, and one stack over it, for one test. The fake stops however
|
|
95
|
+
* `body` ends.
|
|
96
|
+
*/
|
|
97
|
+
export const withFakeStack = async <ROut, E, RIn>(
|
|
98
|
+
providers: Layer.Layer<ROut, E, RIn>,
|
|
99
|
+
answer: (seen: Seen) => Reply,
|
|
100
|
+
body: (stack: FakeStack, bao: Fake) => Promise<void>,
|
|
101
|
+
): Promise<void> => {
|
|
102
|
+
const bao = fakeBao(answer);
|
|
103
|
+
try {
|
|
104
|
+
await body(fakeStack(providers, { BAO_ADDR: bao.address }), bao);
|
|
105
|
+
} finally {
|
|
106
|
+
bao.stop();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Every call that changed something, as `METHOD /v1/path`, in order. */
|
|
111
|
+
export const writesOf = (seen: readonly Seen[]): string[] =>
|
|
112
|
+
seen.filter((each) => each.method !== 'GET').map((each) => `${each.method} ${each.path}`);
|