@homeflare/alchemy 0.5.0 → 0.6.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 +17 -48
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1433 -305
- package/dist/openbao/index.js.map +34 -13
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +87 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login MFA against a fake: a TOTP method found by name in THIS namespace only, and an enforcement
|
|
3
|
+
* whose mount paths resolve to accessors and whose lists always go over as arrays. Ids and names are
|
|
4
|
+
* placeholders.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
|
|
9
|
+
import { planEnforcement, reconcileEnforcement } from './mfa-enforcement.ts';
|
|
10
|
+
import { planTotp, reconcileTotp } from './mfa-totp.ts';
|
|
11
|
+
import { renameProblem } from './mfa-totp-form.ts';
|
|
12
|
+
|
|
13
|
+
const ENV = (address: string) => ({ BAO_ADDR: address, BAO_NAMESPACE: 'team-a' });
|
|
14
|
+
const TOTP = { issuer: 'vault-example', name: 'admin-totp' };
|
|
15
|
+
const METHOD_ID = '00000000-0000-4000-8000-00000000000a';
|
|
16
|
+
const stored = (namespacePath: string, extra: Record<string, unknown> = {}) => ({
|
|
17
|
+
algorithm: 'SHA1',
|
|
18
|
+
digits: 6,
|
|
19
|
+
id: METHOD_ID,
|
|
20
|
+
issuer: 'vault-example',
|
|
21
|
+
key_size: 20,
|
|
22
|
+
max_validation_attempts: 5,
|
|
23
|
+
name: 'admin-totp',
|
|
24
|
+
namespace_path: namespacePath,
|
|
25
|
+
period: 30,
|
|
26
|
+
qr_size: 200,
|
|
27
|
+
skew: 1,
|
|
28
|
+
type: 'totp',
|
|
29
|
+
...extra,
|
|
30
|
+
});
|
|
31
|
+
const listing = (...entries: Record<string, unknown>[]): Reply =>
|
|
32
|
+
entries.length === 0
|
|
33
|
+
? { json: { errors: [] }, status: 404 }
|
|
34
|
+
: {
|
|
35
|
+
json: {
|
|
36
|
+
data: {
|
|
37
|
+
key_info: Object.fromEntries(entries.map((e, i) => [`id-${String(i)}`, e])),
|
|
38
|
+
keys: [],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
status: 200,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Listing replies in order (the last repeats); every write answers 200. */
|
|
45
|
+
const totpFake = (...lists: Reply[]) => {
|
|
46
|
+
let reads = 0;
|
|
47
|
+
return (seen: Seen): Reply => {
|
|
48
|
+
if (seen.method !== 'GET') return { json: { data: { method_id: METHOD_ID } }, status: 200 };
|
|
49
|
+
const reply = lists[Math.min(reads, lists.length - 1)] ?? listing();
|
|
50
|
+
reads += 1;
|
|
51
|
+
return reply;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const writes = (seen: Seen[]) => seen.filter((each) => each.method !== 'GET');
|
|
55
|
+
|
|
56
|
+
describe('Bao.MfaTotpMethod', () => {
|
|
57
|
+
it('upserts by method_name when absent, and returns the id it read back', async () => {
|
|
58
|
+
await withFake(totpFake(listing(), listing(stored('team-a/'))), async (bao) => {
|
|
59
|
+
const attributes = await run(ENV(bao.address), reconcileTotp(TOTP));
|
|
60
|
+
assert.equal(attributes.methodId, 'id-0');
|
|
61
|
+
const [write] = writes(bao.seen);
|
|
62
|
+
assert.equal(write?.path, '/v1/identity/mfa/method/totp');
|
|
63
|
+
const body = JSON.parse(write?.body ?? '{}') as Record<string, unknown>;
|
|
64
|
+
assert.equal(body['method_name'], 'admin-totp');
|
|
65
|
+
assert.equal(body['method_id'], undefined);
|
|
66
|
+
assert.equal(body['period'], '30s');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('plans noop and writes nothing when this namespace already has it', async () => {
|
|
71
|
+
await withFake(totpFake(listing(stored('team-a/'))), async (bao) => {
|
|
72
|
+
assert.equal(await run(ENV(bao.address), planTotp(TOTP)), 'noop');
|
|
73
|
+
await run(ENV(bao.address), reconcileTotp(TOTP));
|
|
74
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores a same-named method in a parent namespace — it is not this namespace's", async () => {
|
|
79
|
+
await withFake(
|
|
80
|
+
totpFake(listing(stored('')), listing(stored(''), stored('team-a/'))),
|
|
81
|
+
async (bao) => {
|
|
82
|
+
await run(ENV(bao.address), reconcileTotp(TOTP));
|
|
83
|
+
assert.equal(writes(bao.seen).length, 1);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refuses a rename at plan time — a new id would strand every enrolled secret', () => {
|
|
89
|
+
assert.equal(renameProblem('admin-totp', 'admin-totp'), undefined);
|
|
90
|
+
assert.match(renameProblem('admin-totp', 'admin-otp') ?? '', /strand every enrolled secret/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('refuses a name another method type holds here — a TOTP write would convert it', async () => {
|
|
94
|
+
await withFake(totpFake(listing(stored('team-a/', { type: 'duo' }))), async (bao) => {
|
|
95
|
+
await assert.rejects(run(ENV(bao.address), reconcileTotp(TOTP)), /belongs to a duo method/);
|
|
96
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
97
|
+
// ★ The GLOBAL listing — the per-type one cannot see a Duo method at all.
|
|
98
|
+
assert.equal(bao.seen[0]?.path, '/v1/identity/mfa/method?list=true');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('plans update on drift, and refuses a bad declaration before any call', async () => {
|
|
103
|
+
await withFake(totpFake(listing(stored('team-a/', { digits: 8 }))), async (bao) => {
|
|
104
|
+
assert.equal(await run(ENV(bao.address), planTotp(TOTP)), 'update');
|
|
105
|
+
});
|
|
106
|
+
await withFake(totpFake(listing()), async (bao) => {
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
run(ENV(bao.address), reconcileTotp({ ...TOTP, issuer: '' })),
|
|
109
|
+
/issuer is empty/,
|
|
110
|
+
);
|
|
111
|
+
assert.equal(bao.seen.length, 0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const AUTH_TABLE: Reply = {
|
|
117
|
+
json: {
|
|
118
|
+
data: {
|
|
119
|
+
'oidc/': { accessor: 'auth_oidc_1', type: 'oidc' },
|
|
120
|
+
'token/': { accessor: 'auth_token_1', type: 'token' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
status: 200,
|
|
124
|
+
};
|
|
125
|
+
const ENFORCE = { authMethodPaths: ['oidc'], mfaMethodIds: [METHOD_ID], name: 'admin-mfa' };
|
|
126
|
+
const enforcementLive = (accessors: string[]) => ({
|
|
127
|
+
json: {
|
|
128
|
+
data: {
|
|
129
|
+
auth_method_accessors: accessors,
|
|
130
|
+
auth_method_types: [],
|
|
131
|
+
id: 'enf-1',
|
|
132
|
+
identity_entity_ids: [],
|
|
133
|
+
identity_group_ids: [],
|
|
134
|
+
mfa_method_ids: [METHOD_ID],
|
|
135
|
+
name: 'admin-mfa',
|
|
136
|
+
namespace_path: 'team-a/',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
status: 200,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/** sys/auth answers the table; the enforcement answers `states` in order; writes answer 204. */
|
|
143
|
+
const enforcementFake = (...states: Reply[]) => {
|
|
144
|
+
let reads = 0;
|
|
145
|
+
return (seen: Seen): Reply => {
|
|
146
|
+
if (seen.path === '/v1/sys/auth') return AUTH_TABLE;
|
|
147
|
+
if (seen.method !== 'GET') return { status: 204 };
|
|
148
|
+
const reply = states[Math.min(reads, states.length - 1)] ?? {
|
|
149
|
+
json: { errors: [] },
|
|
150
|
+
status: 404,
|
|
151
|
+
};
|
|
152
|
+
reads += 1;
|
|
153
|
+
return reply;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe('Bao.MfaLoginEnforcement', () => {
|
|
158
|
+
it('resolves mount paths to accessors and sends every list as an array', async () => {
|
|
159
|
+
const absent = { json: { errors: [] }, status: 404 };
|
|
160
|
+
await withFake(enforcementFake(absent, enforcementLive(['auth_oidc_1'])), async (bao) => {
|
|
161
|
+
const attributes = await run(ENV(bao.address), reconcileEnforcement(ENFORCE));
|
|
162
|
+
assert.equal(attributes.enforcementId, 'enf-1');
|
|
163
|
+
const [write] = writes(bao.seen);
|
|
164
|
+
assert.equal(write?.path, '/v1/identity/mfa/login-enforcement/admin-mfa');
|
|
165
|
+
assert.deepEqual(JSON.parse(write?.body ?? '{}'), {
|
|
166
|
+
auth_method_accessors: ['auth_oidc_1'],
|
|
167
|
+
auth_method_types: [],
|
|
168
|
+
identity_entity_ids: [],
|
|
169
|
+
identity_group_ids: [],
|
|
170
|
+
mfa_method_ids: [METHOD_ID],
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('plans update when live carries a target the declaration does not', async () => {
|
|
176
|
+
await withFake(
|
|
177
|
+
enforcementFake(enforcementLive(['auth_oidc_1', 'auth_token_1'])),
|
|
178
|
+
async (bao) => {
|
|
179
|
+
assert.equal(await run(ENV(bao.address), planEnforcement(ENFORCE)), 'update');
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
await withFake(enforcementFake(enforcementLive(['auth_oidc_1'])), async (bao) => {
|
|
183
|
+
assert.equal(await run(ENV(bao.address), planEnforcement(ENFORCE)), 'noop');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('refuses a path with no mount, and an enforcement with no target, before writing', async () => {
|
|
188
|
+
await withFake(enforcementFake(), async (bao) => {
|
|
189
|
+
await assert.rejects(
|
|
190
|
+
run(ENV(bao.address), reconcileEnforcement({ ...ENFORCE, authMethodPaths: ['jwt-admin'] })),
|
|
191
|
+
/no auth mount at jwt-admin/,
|
|
192
|
+
);
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
run(ENV(bao.address), reconcileEnforcement({ ...ENFORCE, authMethodPaths: [] })),
|
|
195
|
+
/enforces NOTHING/,
|
|
196
|
+
);
|
|
197
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { sha256 } from './digest.ts';
|
|
2
|
+
import { trimTrailingSlashes } from './mount-path.ts';
|
|
2
3
|
|
|
3
4
|
/** Mount path without a trailing slash — props and `sys/mounts/<path>` use this shape. */
|
|
4
|
-
export const mountPath = (path: string) => path
|
|
5
|
+
export const mountPath = (path: string) => trimTrailingSlashes(path);
|
|
5
6
|
|
|
6
7
|
const DURATION = /^(\d+)([smhd])$/;
|
|
7
8
|
|
|
@@ -46,6 +47,13 @@ export interface BaoMountProps {
|
|
|
46
47
|
maxLeaseTtl?: string;
|
|
47
48
|
/** KV only — sent as `options.version` on enable, as `-version=2` was. Immutable after create. */
|
|
48
49
|
version?: 1 | 2;
|
|
50
|
+
/**
|
|
51
|
+
* The path this mount lives at NOW, when `path` is where it should move to. Moves it with
|
|
52
|
+
* `sys/remount`, keeping every secret. Without it, a changed `path` fails the plan — see
|
|
53
|
+
* mount-move.ts. ⛔ Every lease under the old path is revoked, and policies are not rewritten.
|
|
54
|
+
* Harmless to leave declared after the move: once state holds `path`, it is ignored.
|
|
55
|
+
*/
|
|
56
|
+
remountFrom?: string;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export interface BaoMountAttributes {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A changed mount path: refused without `remountFrom`, moved with it — for secrets engines and for
|
|
3
|
+
* auth methods. The fake keeps a live mount table, so the move is judged by what it leaves behind.
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import { reconcileAuthMethod } from './auth-method-reconcile.ts';
|
|
8
|
+
import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
|
|
9
|
+
import { planMove } from './mount-move.ts';
|
|
10
|
+
import { reconcileMount } from './mount-reconcile.ts';
|
|
11
|
+
|
|
12
|
+
describe('planMove', () => {
|
|
13
|
+
it('stays when the path is unchanged, whatever remountFrom says', () => {
|
|
14
|
+
assert.deepEqual(planMove('Bao.Mount', 'kv', 'kv/', undefined), { kind: 'stay' });
|
|
15
|
+
assert.deepEqual(planMove('Bao.Mount', 'kv', 'kv', 'old'), { kind: 'stay' });
|
|
16
|
+
assert.deepEqual(planMove('Bao.Mount', undefined, 'kv', undefined), { kind: 'stay' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('refuses a changed path with no remountFrom, and names the fix', () => {
|
|
20
|
+
const move = planMove('Bao.Mount', 'kv', 'kv2', undefined);
|
|
21
|
+
assert.equal(move.kind, 'refuse');
|
|
22
|
+
assert.match(move.kind === 'refuse' ? move.message : '', /EMPTY mount.*remountFrom: 'kv'/s);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('refuses a remountFrom that is not the path in state', () => {
|
|
26
|
+
assert.equal(planMove('Bao.Mount', 'kv', 'kv2', 'other').kind, 'refuse');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('moves when remountFrom names the stated path, or on a first reconcile', () => {
|
|
30
|
+
assert.deepEqual(planMove('Bao.Mount', 'kv/', 'kv2', 'kv/'), { from: 'kv', kind: 'move' });
|
|
31
|
+
assert.deepEqual(planMove('Bao.Mount', undefined, 'kv2', 'kv'), { from: 'kv', kind: 'move' });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
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
|
+
const posts = (seen: Seen[]) =>
|
|
70
|
+
seen.filter((each) => each.method === 'POST').map((each) => each.path);
|
|
71
|
+
|
|
72
|
+
describe('reconcileMount', () => {
|
|
73
|
+
it('moves kv to its new path with sys/remount, and enables nothing', async () => {
|
|
74
|
+
const live = liveTable('sys/mounts', { kv: 'kv' });
|
|
75
|
+
await withFake(live.answer, async (bao) => {
|
|
76
|
+
const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
|
|
77
|
+
const attributes = await run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv'));
|
|
78
|
+
assert.equal(attributes.path, 'kv-moved');
|
|
79
|
+
assert.deepEqual(posts(bao.seen), ['/v1/sys/remount']);
|
|
80
|
+
assert.deepEqual([...live.table.keys()], ['kv-moved/']);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('refuses a changed path with no remountFrom before any write', async () => {
|
|
85
|
+
const live = liveTable('sys/mounts', { kv: 'kv' });
|
|
86
|
+
await withFake(live.answer, async (bao) => {
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
run({ BAO_ADDR: bao.address }, reconcileMount({ path: 'kv-moved', type: 'kv' }, 'kv')),
|
|
89
|
+
/EMPTY mount/,
|
|
90
|
+
);
|
|
91
|
+
assert.deepEqual(posts(bao.seen), []);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('converges when the move already happened (an interrupted apply re-running)', async () => {
|
|
96
|
+
const live = liveTable('sys/mounts', { 'kv-moved': 'kv' });
|
|
97
|
+
await withFake(live.answer, async (bao) => {
|
|
98
|
+
const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
|
|
99
|
+
await run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv'));
|
|
100
|
+
assert.deepEqual(posts(bao.seen), []);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('refuses when neither path exists, rather than enabling an empty mount', async () => {
|
|
105
|
+
const live = liveTable('sys/mounts', {});
|
|
106
|
+
await withFake(live.answer, async (bao) => {
|
|
107
|
+
const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
|
|
108
|
+
await assert.rejects(
|
|
109
|
+
run({ BAO_ADDR: bao.address }, reconcileMount(props, undefined)),
|
|
110
|
+
/empty mount where data was expected/,
|
|
111
|
+
);
|
|
112
|
+
assert.deepEqual(posts(bao.seen), []);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('refuses when both paths exist, or the source is another type', async () => {
|
|
117
|
+
const both = liveTable('sys/mounts', { kv: 'kv', 'kv-moved': 'kv' });
|
|
118
|
+
await withFake(both.answer, async (bao) => {
|
|
119
|
+
const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
|
|
120
|
+
await assert.rejects(run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv')), /both/);
|
|
121
|
+
});
|
|
122
|
+
const typed = liveTable('sys/mounts', { kv: 'pki' });
|
|
123
|
+
await withFake(typed.answer, async (bao) => {
|
|
124
|
+
const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
|
|
125
|
+
await assert.rejects(run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv')), /type/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('a type change (REPLACE.md)', () => {
|
|
131
|
+
it("fails the replacement's create on the occupied path, before any write", async () => {
|
|
132
|
+
const live = liveTable('sys/mounts', { kv: 'kv' });
|
|
133
|
+
await withFake(live.answer, async (bao) => {
|
|
134
|
+
// ★ A replacement's new generation reconciles with no state: `stated` is undefined.
|
|
135
|
+
const replacement = reconcileMount({ path: 'kv', type: 'pki' }, undefined);
|
|
136
|
+
await assert.rejects(run({ BAO_ADDR: bao.address }, replacement), /immutable/);
|
|
137
|
+
assert.deepEqual(posts(bao.seen), []);
|
|
138
|
+
assert.deepEqual([...live.table.entries()], [['kv/', 'kv']]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('reconcileAuthMethod', () => {
|
|
144
|
+
it('moves an auth method through sys/remount with the auth/ prefix', async () => {
|
|
145
|
+
const live = liveTable('sys/auth', { jwt: 'jwt' });
|
|
146
|
+
await withFake(live.answer, async (bao) => {
|
|
147
|
+
const props = { path: 'jwt-humans', remountFrom: 'jwt', type: 'jwt' };
|
|
148
|
+
await run({ BAO_ADDR: bao.address }, reconcileAuthMethod(props, 'jwt'));
|
|
149
|
+
const start = bao.seen.find((seen) => seen.path === '/v1/sys/remount');
|
|
150
|
+
assert.equal(start?.body, JSON.stringify({ from: 'auth/jwt', to: 'auth/jwt-humans' }));
|
|
151
|
+
assert.deepEqual([...live.table.keys()], ['jwt-humans/']);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('refuses a changed auth path with no remountFrom', async () => {
|
|
156
|
+
const live = liveTable('sys/auth', { jwt: 'jwt' });
|
|
157
|
+
await withFake(live.answer, async (bao) => {
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
run({ BAO_ADDR: bao.address }, reconcileAuthMethod({ path: 'jwt2', type: 'jwt' }, 'jwt')),
|
|
160
|
+
/Bao\.AuthMethod: path changed/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a changed path means for Bao.Mount and Bao.AuthMethod — and the move, when one is declared.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A CHANGED PATH USED TO PLAN `update` AND ENABLE AN EMPTY MOUNT AT THE NEW PATH. diff read the
|
|
5
|
+
* new path, found nothing, answered `update`, and reconcile ran `enable` there: a fresh, empty
|
|
6
|
+
* `kv2/` beside the retained `kv/` that still held every secret, with Alchemy's state now naming
|
|
7
|
+
* only the empty one. Nothing failed, so nothing said so (site-config plan, Guards).
|
|
8
|
+
* ★ NOW A CHANGED PATH FAILS AT PLAN TIME, unless the declaration says where it came from:
|
|
9
|
+
* `remountFrom: '<old path>'` moves the mount with `sys/remount` (remount-wire.ts), which keeps
|
|
10
|
+
* every secret. A genuinely new, empty mount is a new resource id, never an edited path.
|
|
11
|
+
* ★ THE MOVE IS DECIDED BY LIVE STATE, SO A RE-RUN CONVERGES. Source present and target absent is
|
|
12
|
+
* a move; target present and source absent is a move that already happened; both present, or
|
|
13
|
+
* neither, is a refusal — "neither" in particular is exactly the empty-mount failure above.
|
|
14
|
+
*/
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
17
|
+
import type { BaoError } from './bao-status.ts';
|
|
18
|
+
import { mountPath } from './mount-form.ts';
|
|
19
|
+
import { type RemountError, type RemountOptions, remountAndWait } from './remount-wire.ts';
|
|
20
|
+
|
|
21
|
+
export type Move =
|
|
22
|
+
| { readonly kind: 'stay' }
|
|
23
|
+
| { readonly kind: 'move'; readonly from: string }
|
|
24
|
+
| { readonly kind: 'refuse'; readonly message: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param family `Bao.Mount` or `Bao.AuthMethod`, for the message.
|
|
28
|
+
* @param stated the path Alchemy's state holds, or undefined on a first reconcile.
|
|
29
|
+
* @param path the declared path.
|
|
30
|
+
* @param remountFrom the declared source, if any.
|
|
31
|
+
*/
|
|
32
|
+
export const planMove = (
|
|
33
|
+
family: string,
|
|
34
|
+
stated: string | undefined,
|
|
35
|
+
path: string,
|
|
36
|
+
remountFrom: string | undefined,
|
|
37
|
+
): Move => {
|
|
38
|
+
const to = mountPath(path);
|
|
39
|
+
const from = remountFrom === undefined ? undefined : mountPath(remountFrom);
|
|
40
|
+
if (stated === undefined) {
|
|
41
|
+
return from === undefined || from === to ? { kind: 'stay' } : { kind: 'move', from };
|
|
42
|
+
}
|
|
43
|
+
const was = mountPath(stated);
|
|
44
|
+
if (was === to) return { kind: 'stay' };
|
|
45
|
+
if (from === undefined) {
|
|
46
|
+
return {
|
|
47
|
+
kind: 'refuse',
|
|
48
|
+
message:
|
|
49
|
+
`${family}: path changed from ${was} to ${to}. That would enable an EMPTY mount at ${to} ` +
|
|
50
|
+
`and strand everything under ${was}. To move it with its data, declare ` +
|
|
51
|
+
`\`remountFrom: '${was}'\` (leases under it are revoked; policies are not rewritten). ` +
|
|
52
|
+
'For a new, empty mount, give it a new resource id instead.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (from !== was) {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'refuse',
|
|
58
|
+
message: `${family}: remountFrom is ${from}, but the mount in state is ${was}.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { kind: 'move', from };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export interface MoveTarget {
|
|
65
|
+
readonly family: string;
|
|
66
|
+
/** Declared path, bare. */
|
|
67
|
+
readonly path: string;
|
|
68
|
+
readonly type: string;
|
|
69
|
+
/** Read one path's live entry — undefined when absent. */
|
|
70
|
+
readonly read: (
|
|
71
|
+
path: string,
|
|
72
|
+
) => Effect.Effect<{ readonly type: string } | undefined, BaoError, HttpClient.HttpClient>;
|
|
73
|
+
/** The path as sys/remount takes it: the bare path for secrets, `auth/<path>` for auth. */
|
|
74
|
+
readonly wire: (path: string) => string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const refuse = (message: string) => Effect.die(new Error(message));
|
|
78
|
+
|
|
79
|
+
/** Perform a planned move, deciding by live state. Returns once the target is in place. */
|
|
80
|
+
export const performMove = (
|
|
81
|
+
target: MoveTarget,
|
|
82
|
+
from: string,
|
|
83
|
+
options?: RemountOptions,
|
|
84
|
+
): Effect.Effect<void, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const to = mountPath(target.path);
|
|
87
|
+
const source = yield* target.read(from);
|
|
88
|
+
const landed = yield* target.read(to);
|
|
89
|
+
if (source !== undefined && landed !== undefined) {
|
|
90
|
+
return yield* refuse(
|
|
91
|
+
`${target.family}: both ${from} and ${to} exist, so remountFrom cannot move one onto the ` +
|
|
92
|
+
'other. Decide which holds the data, and retire the other by hand.',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (source === undefined && landed === undefined) {
|
|
96
|
+
return yield* refuse(
|
|
97
|
+
`${target.family}: remountFrom ${from} names no mount, and ${to} does not exist either. ` +
|
|
98
|
+
'Refusing to enable an empty mount where data was expected.',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (source === undefined) return; // ★ Already moved — an interrupted apply re-running.
|
|
102
|
+
if (source.type !== target.type) {
|
|
103
|
+
return yield* refuse(
|
|
104
|
+
`${target.family}: ${from} is type ${source.type}, not ${target.type}. A remount keeps the ` +
|
|
105
|
+
'type; fix the declaration first.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const confirm = Effect.map(
|
|
109
|
+
Effect.all([target.read(from), target.read(to)]),
|
|
110
|
+
([gone, there]) => gone === undefined && there !== undefined,
|
|
111
|
+
);
|
|
112
|
+
yield* remountAndWait(target.wire(from), target.wire(to), confirm, options);
|
|
113
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
5
|
import { describe, it } from 'node:test';
|
|
6
|
-
import { trimSlashes, trimTrailingSlashes } from './mount-path.ts';
|
|
6
|
+
import { trimRuns, trimSlashes, trimTrailingSlashes } from './mount-path.ts';
|
|
7
7
|
|
|
8
8
|
describe('mount-path', () => {
|
|
9
9
|
it('trims outer slashes and keeps inner ones', () => {
|
|
@@ -21,3 +21,13 @@ describe('mount-path', () => {
|
|
|
21
21
|
assert.ok(performance.now() - started < 1_000, 'slash trimming took over a second');
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
describe('trimRuns', () => {
|
|
26
|
+
it('trims any single character and stays linear on long runs', () => {
|
|
27
|
+
assert.equal(trimRuns('--a-b--', '-'), 'a-b');
|
|
28
|
+
const run = '-'.repeat(200_000);
|
|
29
|
+
const started = performance.now();
|
|
30
|
+
assert.equal(trimRuns(`${run}x${run}y`, '-'), `x${run}y`);
|
|
31
|
+
assert.ok(performance.now() - started < 1_000, 'trimming took over a second');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Trimming runs of one character from caller-supplied paths and names.
|
|
3
3
|
*
|
|
4
|
-
* ★ LOOPS, NOT `/^\/+|\/+$/g
|
|
5
|
-
* of
|
|
6
|
-
*
|
|
4
|
+
* ★ LOOPS, NOT `/^\/+|\/+$/g`, `/\/+$/` OR `/^-+|-+$/g`. A trailing `x+$` backtracks polynomially
|
|
5
|
+
* on a long run of `x` followed by anything else (CodeQL js/polynomial-redos), and mount paths,
|
|
6
|
+
* namespaces and slugs are caller input. Every loop here is linear in the input length.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/** `text` without trailing
|
|
10
|
-
export const
|
|
9
|
+
/** `text` without a trailing run of `char` (one UTF-16 unit). */
|
|
10
|
+
export const trimTrailing = (text: string, char: string): string => {
|
|
11
11
|
let end = text.length;
|
|
12
|
-
while (end > 0 && text[end - 1] ===
|
|
12
|
+
while (end > 0 && text[end - 1] === char) end -= 1;
|
|
13
13
|
return text.slice(0, end);
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
/** `text` without leading or trailing
|
|
17
|
-
export const
|
|
16
|
+
/** `text` without a leading or trailing run of `char` (one UTF-16 unit). */
|
|
17
|
+
export const trimRuns = (text: string, char: string): string => {
|
|
18
18
|
let start = 0;
|
|
19
|
-
while (start < text.length && text[start] ===
|
|
20
|
-
return
|
|
19
|
+
while (start < text.length && text[start] === char) start += 1;
|
|
20
|
+
return trimTrailing(text.slice(start), char);
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/** `text` without trailing `/`. */
|
|
24
|
+
export const trimTrailingSlashes = (text: string): string => trimTrailing(text, '/');
|
|
25
|
+
|
|
26
|
+
/** `text` without leading or trailing `/`. */
|
|
27
|
+
export const trimSlashes = (text: string): string => trimRuns(text, '/');
|
|
28
|
+
|
|
23
29
|
/** SSH mount path without a trailing slash; `ssh` when the caller did not say. */
|
|
24
30
|
export const sshMountOf = (mount: string | undefined): string =>
|
|
25
31
|
trimTrailingSlashes(mount ?? 'ssh');
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.Mount's read and reconcile as plain effects — split from mount.ts so the move (and its
|
|
3
|
+
* refusals) run against a fake server, not only in a reviewer's head.
|
|
4
|
+
*/
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
7
|
+
import type { BaoError } from './bao-status.ts';
|
|
8
|
+
import {
|
|
9
|
+
type BaoMountAttributes,
|
|
10
|
+
type BaoMountProps,
|
|
11
|
+
attributesOf,
|
|
12
|
+
matches,
|
|
13
|
+
mountPath,
|
|
14
|
+
wantsTune,
|
|
15
|
+
} from './mount-form.ts';
|
|
16
|
+
import { type MoveTarget, performMove, planMove } from './mount-move.ts';
|
|
17
|
+
import { enableMount, readMountData, tuneMount } from './mount-wire.ts';
|
|
18
|
+
import type { RemountError, RemountOptions } from './remount-wire.ts';
|
|
19
|
+
|
|
20
|
+
export const readMount = (
|
|
21
|
+
props: BaoMountProps,
|
|
22
|
+
): Effect.Effect<BaoMountAttributes | undefined, BaoError, HttpClient.HttpClient> =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const live = yield* readMountData(props.path);
|
|
25
|
+
if (live === undefined) return undefined;
|
|
26
|
+
return attributesOf(props, live);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const moveTarget = (props: BaoMountProps): MoveTarget => ({
|
|
30
|
+
family: 'Bao.Mount',
|
|
31
|
+
path: props.path,
|
|
32
|
+
read: (path) => readMount({ ...props, path }),
|
|
33
|
+
type: props.type,
|
|
34
|
+
wire: mountPath,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converge one mount. `stated` is the path in Alchemy's state (undefined on a first reconcile).
|
|
39
|
+
* Dies on a refusal; fails with OpenBao's own error on a refused call.
|
|
40
|
+
*/
|
|
41
|
+
export const reconcileMount = (
|
|
42
|
+
news: BaoMountProps,
|
|
43
|
+
stated: string | undefined,
|
|
44
|
+
options?: RemountOptions,
|
|
45
|
+
): Effect.Effect<BaoMountAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const move = planMove('Bao.Mount', stated, news.path, news.remountFrom);
|
|
48
|
+
if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
|
|
49
|
+
if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
|
|
50
|
+
|
|
51
|
+
let live = yield* readMount(news);
|
|
52
|
+
if (live === undefined) {
|
|
53
|
+
yield* enableMount(news);
|
|
54
|
+
if (wantsTune(news)) yield* tuneMount(news);
|
|
55
|
+
} else if (!matches(live, news)) {
|
|
56
|
+
if (live.type !== news.type) {
|
|
57
|
+
return yield* Effect.die(
|
|
58
|
+
new Error(
|
|
59
|
+
`Bao.Mount ${mountPath(news.path)}: live type ${live.type} != ${news.type}. ` +
|
|
60
|
+
'Secrets engine type is immutable — replace manually.',
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
yield* tuneMount(news);
|
|
65
|
+
}
|
|
66
|
+
live = yield* readMount(news);
|
|
67
|
+
if (live === undefined) {
|
|
68
|
+
return yield* Effect.die(
|
|
69
|
+
new Error(
|
|
70
|
+
`Bao.Mount ${mountPath(news.path)}: write returned no error but the mount is still absent.`,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return live;
|
|
75
|
+
});
|