@homeflare/alchemy 0.37.5 → 0.37.7
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/dist/openbao/auth-role-form.d.ts +0 -6
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-identity.d.ts +11 -0
- package/dist/openbao/auth-role-identity.d.ts.map +1 -0
- package/dist/openbao/auth-role-wire.d.ts +23 -0
- package/dist/openbao/auth-role-wire.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/bao-http.d.ts +34 -13
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/distilled.d.ts +25 -0
- package/dist/openbao/distilled.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +5 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +358 -305
- package/dist/openbao/index.js.map +13 -9
- package/dist/openbao/policy-identity.d.ts +12 -0
- package/dist/openbao/policy-identity.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +9 -8
- package/dist/openbao/policy-wire.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +5 -0
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-config.d.ts +36 -0
- package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +3 -1
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
- package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +2 -8
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/index.js +795 -526
- package/dist/proxmox/index.js.map +24 -11
- package/dist/proxmox/lxc-errors.d.ts +12 -0
- package/dist/proxmox/lxc-errors.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/lxc-read.d.ts +4 -0
- package/dist/proxmox/lxc-read.d.ts.map +1 -0
- package/dist/proxmox/lxc-task.d.ts +15 -0
- package/dist/proxmox/lxc-task.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +7 -14
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply-run.d.ts +7 -0
- package/dist/proxmox/network-apply-run.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +2 -3
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/dist/proxmox/qemu-errors.d.ts +5 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -0
- package/dist/proxmox/qemu-form.d.ts +22 -0
- package/dist/proxmox/qemu-form.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/qemu-read.d.ts +5 -0
- package/dist/proxmox/qemu-read.d.ts.map +1 -0
- package/dist/proxmox/qemu-task.d.ts +9 -0
- package/dist/proxmox/qemu-task.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +9 -14
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
- package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
- package/dist/proxmox/replication-job-config.d.ts +56 -0
- package/dist/proxmox/replication-job-config.d.ts.map +1 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +5 -6
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/index.js +3 -2
- package/dist/release/index.js.map +3 -3
- package/docs/proxmox-lxc-distilled.md +56 -0
- package/docs/proxmox-replication-firewall-distilled.md +53 -0
- package/docs/proxmox-sdk-migration.md +23 -18
- package/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/openbao/auth-role-form.test.ts +11 -9
- package/src/openbao/auth-role-form.ts +0 -19
- package/src/openbao/auth-role-identity.ts +24 -0
- package/src/openbao/auth-role-sdk.test.ts +155 -0
- package/src/openbao/auth-role-wire.test.ts +128 -0
- package/src/openbao/auth-role-wire.ts +89 -0
- package/src/openbao/auth-role.ts +9 -9
- package/src/openbao/bao-http.ts +9 -7
- package/src/openbao/distilled-retry.test.ts +164 -0
- package/src/openbao/distilled.test.ts +146 -0
- package/src/openbao/distilled.ts +88 -0
- package/src/openbao/fake-engines-roles.ts +9 -2
- package/src/openbao/policy-distilled.test.ts +98 -0
- package/src/openbao/policy-identity.ts +24 -0
- package/src/openbao/policy-rename.test.ts +2 -2
- package/src/openbao/policy-wire.test.ts +5 -5
- package/src/openbao/policy-wire.ts +31 -11
- package/src/openbao/policy.ts +10 -13
- package/src/openbao/rename.ts +17 -1
- package/src/proxmox/firewall-alias-config.ts +39 -0
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
- package/src/proxmox/firewall-alias.ts +4 -67
- package/src/proxmox/lxc-create.test.ts +4 -1
- package/src/proxmox/lxc-distilled.test.ts +239 -0
- package/src/proxmox/lxc-errors.ts +9 -0
- package/src/proxmox/lxc-lifecycle.ts +31 -113
- package/src/proxmox/lxc-read.test.ts +29 -6
- package/src/proxmox/lxc-read.ts +88 -0
- package/src/proxmox/lxc-task.ts +71 -0
- package/src/proxmox/network-apply-distilled.test.ts +76 -0
- package/src/proxmox/network-apply-read.ts +18 -39
- package/src/proxmox/network-apply-run.ts +97 -0
- package/src/proxmox/network-apply.ts +5 -94
- package/src/proxmox/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
- package/src/proxmox/qemu-errors.ts +7 -0
- package/src/proxmox/qemu-form.ts +15 -0
- package/src/proxmox/qemu-lifecycle.test.ts +171 -0
- package/src/proxmox/qemu-lifecycle.ts +134 -0
- package/src/proxmox/qemu-read.ts +73 -0
- package/src/proxmox/qemu-task.ts +64 -0
- package/src/proxmox/qemu.ts +11 -44
- package/src/proxmox/replication-firewall-fixtures.ts +71 -0
- package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
- package/src/proxmox/replication-firewall-wire.test.ts +173 -0
- package/src/proxmox/replication-job-config.ts +79 -0
- package/src/proxmox/replication-job-lifecycle.ts +113 -0
- package/src/proxmox/replication-job.ts +7 -84
- package/src/release/archive.ts +4 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { policiesReadAclPolicy } from '@distilled.cloud/openbao/policies';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
5
|
+
import { HttpClientError, TransportError } from 'effect/unstable/http/HttpClientError';
|
|
6
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
7
|
+
import { readAuthRole, writeAuthRole } from './auth-role-wire.ts';
|
|
8
|
+
import { BaoEnv } from './bao-http.ts';
|
|
9
|
+
import { BaoError } from './bao-status.ts';
|
|
10
|
+
import { runBaoRead } from './distilled.ts';
|
|
11
|
+
import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
|
|
12
|
+
|
|
13
|
+
const ROLE_PROPS = {
|
|
14
|
+
name: 'test',
|
|
15
|
+
tokenPolicies: ['default'],
|
|
16
|
+
tokenTtl: '15m',
|
|
17
|
+
tokenMaxTtl: '1h',
|
|
18
|
+
secretIdTtl: '2160h',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const TOKEN = 'fixture-retry-token';
|
|
22
|
+
const POLICY = { policy: 'path "test" {}' };
|
|
23
|
+
const ROLE = {
|
|
24
|
+
bind_secret_id: true,
|
|
25
|
+
secret_id_num_uses: 0,
|
|
26
|
+
secret_id_ttl: 0,
|
|
27
|
+
token_max_ttl: 3600,
|
|
28
|
+
token_policies: ['default'],
|
|
29
|
+
token_ttl: 900,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const runWith = <A, E>(
|
|
33
|
+
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
|
|
34
|
+
client: HttpClient.HttpClient,
|
|
35
|
+
) =>
|
|
36
|
+
Effect.runPromise(
|
|
37
|
+
effect.pipe(
|
|
38
|
+
Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid', BAO_TOKEN: TOKEN }),
|
|
39
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
for (const [name, read, data] of [
|
|
44
|
+
['Policy', Effect.asVoid(readPolicy('test')), POLICY],
|
|
45
|
+
['AppRole', Effect.asVoid(readAuthRole('test')), ROLE],
|
|
46
|
+
] as const) {
|
|
47
|
+
test(`${name} retries a dropped read and then returns the live object`, async () => {
|
|
48
|
+
let attempts = 0;
|
|
49
|
+
const client = HttpClient.make((request) => {
|
|
50
|
+
attempts++;
|
|
51
|
+
expect(request.method).toBe('GET');
|
|
52
|
+
return attempts === 1
|
|
53
|
+
? Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }))
|
|
54
|
+
: Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data })));
|
|
55
|
+
});
|
|
56
|
+
await runWith(read, client);
|
|
57
|
+
expect(attempts).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('read transport retries stop after three attempts without exposing the token', async () => {
|
|
62
|
+
let attempts = 0;
|
|
63
|
+
const client = HttpClient.make((request) => {
|
|
64
|
+
attempts++;
|
|
65
|
+
return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
|
|
66
|
+
});
|
|
67
|
+
const error = await runWith(Effect.flip(readPolicy('test')), client);
|
|
68
|
+
expect(attempts).toBe(3);
|
|
69
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
70
|
+
expect(JSON.stringify(error)).not.toContain(TOKEN);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A FULL-REPLACE write sends the complete desired state on every attempt, so — unlike a
|
|
75
|
+
* create or a non-idempotent call — it is safe to replay after a transport-level failure.
|
|
76
|
+
* `runBaoWrite` restores the bounded retry `baoCall()` gave every call before the distilled
|
|
77
|
+
* SDK path landed with `Retry.none`.
|
|
78
|
+
*/
|
|
79
|
+
for (const [name, write] of [
|
|
80
|
+
['Policy', writePolicy('test', 'path "test" {}')],
|
|
81
|
+
['AppRole', writeAuthRole(ROLE_PROPS)],
|
|
82
|
+
] as const) {
|
|
83
|
+
test(`${name} write retries a dropped transport attempt and then succeeds`, async () => {
|
|
84
|
+
let attempts = 0;
|
|
85
|
+
const client = HttpClient.make((request) => {
|
|
86
|
+
attempts++;
|
|
87
|
+
expect(request.method).toBe('POST');
|
|
88
|
+
return attempts === 1
|
|
89
|
+
? Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }))
|
|
90
|
+
: Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })));
|
|
91
|
+
});
|
|
92
|
+
await runWith(write, client);
|
|
93
|
+
expect(attempts).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test(`${name} write transport retries stop after three attempts without exposing the token`, async () => {
|
|
97
|
+
let attempts = 0;
|
|
98
|
+
const client = HttpClient.make((request) => {
|
|
99
|
+
attempts++;
|
|
100
|
+
return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
|
|
101
|
+
});
|
|
102
|
+
const error = await runWith(Effect.flip(write), client);
|
|
103
|
+
expect(attempts).toBe(3);
|
|
104
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
105
|
+
expect(JSON.stringify(error)).not.toContain(TOKEN);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test('a read timeout has a fresh bound on its next attempt', async () => {
|
|
110
|
+
let attempts = 0;
|
|
111
|
+
const client = HttpClient.make((request) => {
|
|
112
|
+
attempts++;
|
|
113
|
+
return attempts === 1
|
|
114
|
+
? Effect.never
|
|
115
|
+
: Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: POLICY })));
|
|
116
|
+
});
|
|
117
|
+
await runWith(runBaoRead(policiesReadAclPolicy({ name: 'test' }), '20 millis'), client);
|
|
118
|
+
expect(attempts).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Delete stays single-attempt: it is idempotent (a policy or role already gone is success,
|
|
123
|
+
* per policy-wire.ts / auth-role-wire.ts), but it is not a full-replace write, and this suite
|
|
124
|
+
* scopes the restored retry to the writes that carry the complete desired state on the wire.
|
|
125
|
+
*/
|
|
126
|
+
test('an uncertain delete is never replayed', async () => {
|
|
127
|
+
let attempts = 0;
|
|
128
|
+
const client = HttpClient.make((request) => {
|
|
129
|
+
attempts++;
|
|
130
|
+
return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
|
|
131
|
+
});
|
|
132
|
+
const error = await runWith(Effect.flip(deletePolicy('test')), client);
|
|
133
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
134
|
+
expect(attempts).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('typed denied reads are returned immediately without transport retries', async () => {
|
|
138
|
+
let attempts = 0;
|
|
139
|
+
const client = HttpClient.make((request) => {
|
|
140
|
+
attempts++;
|
|
141
|
+
return Effect.succeed(
|
|
142
|
+
HttpClientResponse.fromWeb(
|
|
143
|
+
request,
|
|
144
|
+
Response.json({ errors: ['permission denied'] }, { status: 403 }),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
const error = await runWith(Effect.flip(readPolicy('test')), client);
|
|
149
|
+
expect(error).toMatchObject({ _tag: 'Forbidden' });
|
|
150
|
+
expect(attempts).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('SDK normalization accepts an address that already carries the API prefix', async () => {
|
|
154
|
+
let observed = '';
|
|
155
|
+
const client = HttpClient.make((request) => {
|
|
156
|
+
observed = request.url;
|
|
157
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: POLICY })));
|
|
158
|
+
});
|
|
159
|
+
await runWith(
|
|
160
|
+
readPolicy('test').pipe(Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid/v1/' })),
|
|
161
|
+
client,
|
|
162
|
+
);
|
|
163
|
+
expect(observed).toBe('http://fake.invalid/v1/sys/policies/acl/test');
|
|
164
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { policiesReadAclPolicy, policiesWriteAclPolicy } from '@distilled.cloud/openbao/policies';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
8
|
+
import * as Headers from 'effect/unstable/http/Headers';
|
|
9
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
10
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
11
|
+
import { BaoEnv, BaoGate, MAX_IN_FLIGHT, baoRead } from './bao-http.ts';
|
|
12
|
+
import { runBao } from './distilled.ts';
|
|
13
|
+
import { fakeBao, run, runFailure, withFake } from './fake-bao.ts';
|
|
14
|
+
import { BaoError } from './bao-status.ts';
|
|
15
|
+
import { readPolicy } from './policy-wire.ts';
|
|
16
|
+
|
|
17
|
+
const OK = { json: { data: { policy: 'path "test" {}' } }, status: 200 };
|
|
18
|
+
|
|
19
|
+
test('SDK calls resolve BAO/VAULT values per call, retaining empty BAO precedence', async () => {
|
|
20
|
+
await withFake(
|
|
21
|
+
() => OK,
|
|
22
|
+
async (bao) => {
|
|
23
|
+
const env = {
|
|
24
|
+
VAULT_ADDR: bao.address,
|
|
25
|
+
VAULT_NAMESPACE: 'tenant',
|
|
26
|
+
VAULT_TOKEN: 'first',
|
|
27
|
+
BAO_TOKEN: '',
|
|
28
|
+
};
|
|
29
|
+
const read = readPolicy('test');
|
|
30
|
+
await run(env, read);
|
|
31
|
+
env.BAO_TOKEN = 'rotated';
|
|
32
|
+
await run(env, read);
|
|
33
|
+
expect(bao.seen[0]?.headers.has('x-vault-token')).toBe(false);
|
|
34
|
+
expect(bao.seen[1]?.headers.get('x-vault-token')).toBe('rotated');
|
|
35
|
+
expect(bao.seen[1]?.headers.get('x-vault-namespace')).toBe('tenant');
|
|
36
|
+
expect(bao.seen[1]?.headers.get('x-vault-request')).toBe('true');
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('SDK calls use the agent unix socket ahead of a direct address, without a token header', async () => {
|
|
42
|
+
const socket = join(tmpdir(), `distilled-bao-${process.pid}.sock`);
|
|
43
|
+
rmSync(socket, { force: true });
|
|
44
|
+
try {
|
|
45
|
+
await withFake(
|
|
46
|
+
() => OK,
|
|
47
|
+
async (bao) => {
|
|
48
|
+
await run(
|
|
49
|
+
{ BAO_ADDR: 'http://wrong.invalid', BAO_AGENT_ADDR: bao.address },
|
|
50
|
+
readPolicy('test'),
|
|
51
|
+
);
|
|
52
|
+
expect(bao.seen[0]?.path).toBe('/v1/sys/policies/acl/test');
|
|
53
|
+
expect(bao.seen[0]?.headers.has('x-vault-token')).toBe(false);
|
|
54
|
+
expect(bao.seen[0]?.headers.has('x-vault-namespace')).toBe(false);
|
|
55
|
+
},
|
|
56
|
+
socket,
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
rmSync(socket, { force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('legacy and SDK calls share the same eight in-flight permits', async () => {
|
|
64
|
+
let active = 0;
|
|
65
|
+
let peak = 0;
|
|
66
|
+
await withFake(
|
|
67
|
+
async () => {
|
|
68
|
+
active++;
|
|
69
|
+
peak = Math.max(peak, active);
|
|
70
|
+
await Bun.sleep(15);
|
|
71
|
+
active--;
|
|
72
|
+
return OK;
|
|
73
|
+
},
|
|
74
|
+
async (bao) => {
|
|
75
|
+
await run(
|
|
76
|
+
{ BAO_ADDR: bao.address },
|
|
77
|
+
Effect.all(
|
|
78
|
+
Array.from({ length: 32 }, (_, i) =>
|
|
79
|
+
i % 2 === 0 ? Effect.asVoid(readPolicy(`p${i}`)) : Effect.asVoid(baoRead(`p${i}`)),
|
|
80
|
+
),
|
|
81
|
+
{ concurrency: 'unbounded' },
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
expect(peak).toBe(MAX_IN_FLIGHT);
|
|
85
|
+
expect(bao.seen).toHaveLength(32);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a sent write is bounded, not retried, and releases its permit', async () => {
|
|
91
|
+
await withFake(
|
|
92
|
+
async ({ method }) => {
|
|
93
|
+
if (method === 'POST') {
|
|
94
|
+
await Bun.sleep(120);
|
|
95
|
+
return { status: 204 };
|
|
96
|
+
}
|
|
97
|
+
return OK;
|
|
98
|
+
},
|
|
99
|
+
async (bao) => {
|
|
100
|
+
const gate = Semaphore.makeUnsafe(1);
|
|
101
|
+
const write = runBao(
|
|
102
|
+
policiesWriteAclPolicy({ name: 'test', policy: 'path "test" {}' }),
|
|
103
|
+
'30 millis',
|
|
104
|
+
);
|
|
105
|
+
const error = await runFailure(
|
|
106
|
+
{ BAO_ADDR: bao.address },
|
|
107
|
+
write.pipe(Effect.provideService(BaoGate, gate)),
|
|
108
|
+
);
|
|
109
|
+
expect(error).toMatchObject({ _tag: 'TimeoutError' });
|
|
110
|
+
await run(
|
|
111
|
+
{ BAO_ADDR: bao.address },
|
|
112
|
+
readPolicy('test').pipe(Effect.provideService(BaoGate, gate)),
|
|
113
|
+
);
|
|
114
|
+
expect(bao.seen.filter(({ method }) => method === 'POST')).toHaveLength(1);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the SDK keeps x-vault-token in Effect trace redaction alongside default names', async () => {
|
|
120
|
+
let names: ReadonlyArray<RegExp | string> = [];
|
|
121
|
+
const client = HttpClient.make((request, _url, _signal, fiber) => {
|
|
122
|
+
names = fiber.getRef(Headers.CurrentRedactedNames);
|
|
123
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(OK.json)));
|
|
124
|
+
});
|
|
125
|
+
await Effect.runPromise(
|
|
126
|
+
runBao(policiesReadAclPolicy({ name: 'test' })).pipe(
|
|
127
|
+
Effect.provideService(BaoEnv, {
|
|
128
|
+
BAO_ADDR: 'http://fake.invalid',
|
|
129
|
+
BAO_TOKEN: 'never-log-this',
|
|
130
|
+
}),
|
|
131
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
expect(names).toContain('x-vault-token');
|
|
135
|
+
expect(names).toContain('authorization');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('transport failures cannot serialize the request token after the fiber context unwinds', async () => {
|
|
139
|
+
const gone = fakeBao(() => OK);
|
|
140
|
+
gone.stop();
|
|
141
|
+
const token = 'fixture-private-bao-token';
|
|
142
|
+
const error = await runFailure({ BAO_ADDR: gone.address, BAO_TOKEN: token }, readPolicy('test'));
|
|
143
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
144
|
+
expect(JSON.stringify(error)).not.toContain(token);
|
|
145
|
+
expect(String(error)).not.toContain(token);
|
|
146
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK shares Bao's existing call-time environment and process-wide concurrency gate.
|
|
3
|
+
* Merely supplying HttpClient does not apply that gate: legacy calls acquire it in baoCall.
|
|
4
|
+
* ⛔ `runBao` ITSELF NEVER RETRIES: an interrupted call may already have reached the vault, and
|
|
5
|
+
* replaying a non-idempotent operation (a delete whose 404-is-success path already ran, a login,
|
|
6
|
+
* a credential issuance) could repeat a side effect. `runBaoRead` and `runBaoWrite` opt specific,
|
|
7
|
+
* known-idempotent call shapes back into the bounded transport retry baoCall always had.
|
|
8
|
+
*/
|
|
9
|
+
import { Credentials, normalizeBaseUrl } from '@distilled.cloud/openbao/Credentials';
|
|
10
|
+
import type { OpenBaoOpContext } from '@distilled.cloud/openbao/Protocol';
|
|
11
|
+
import * as Retry from '@distilled.cloud/openbao/Retry';
|
|
12
|
+
import type * as Cause from 'effect/Cause';
|
|
13
|
+
import type * as Duration from 'effect/Duration';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Redacted from 'effect/Redacted';
|
|
16
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
17
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
18
|
+
import { isHttpClientError } from 'effect/unstable/http/HttpClientError';
|
|
19
|
+
import { readBaoVariable, resolveAddress } from './bao-address.ts';
|
|
20
|
+
import {
|
|
21
|
+
BAO_TIMEOUT,
|
|
22
|
+
BaoEnv,
|
|
23
|
+
BaoGate,
|
|
24
|
+
overSocket,
|
|
25
|
+
redactingToken,
|
|
26
|
+
retryTransport,
|
|
27
|
+
} from './bao-http.ts';
|
|
28
|
+
import { BaoError } from './bao-status.ts';
|
|
29
|
+
|
|
30
|
+
export const runBao = <A, E>(
|
|
31
|
+
operation: Effect.Effect<A, E, OpenBaoOpContext>,
|
|
32
|
+
timeout: Duration.Input = BAO_TIMEOUT,
|
|
33
|
+
): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const env = yield* BaoEnv;
|
|
36
|
+
const address = resolveAddress(env);
|
|
37
|
+
const token = readBaoVariable(env, 'BAO_TOKEN');
|
|
38
|
+
const gate = yield* BaoGate;
|
|
39
|
+
return yield* operation.pipe(
|
|
40
|
+
Effect.provideService(
|
|
41
|
+
Credentials,
|
|
42
|
+
Effect.succeed({
|
|
43
|
+
apiBaseUrl: normalizeBaseUrl(address.base),
|
|
44
|
+
...(address.namespace === undefined ? {} : { namespace: address.namespace }),
|
|
45
|
+
...(token === '' ? {} : { token: Redacted.make(token) }),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
Retry.none,
|
|
49
|
+
// ⛔ HttpClientError holds the request, including X-Vault-Token. Trace redaction is
|
|
50
|
+
// fiber-local and gone when an error is serialized later; keep only its safe message,
|
|
51
|
+
// as baoCall does. SDK status/resource errors retain their original typed identity.
|
|
52
|
+
Effect.mapError((cause) =>
|
|
53
|
+
isHttpClientError(cause) ? new BaoError(0, 'SDK', 'operation', [cause.message]) : cause,
|
|
54
|
+
),
|
|
55
|
+
// ★ A queued call has not reached the server: acquire the shared permit outside the timeout.
|
|
56
|
+
Effect.timeout(timeout),
|
|
57
|
+
overSocket(address.socket, 'SDK', 'operation'),
|
|
58
|
+
redactingToken,
|
|
59
|
+
Semaphore.withPermits(gate, 1),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads use the existing two transport retries (750 ms between attempts). Each attempt
|
|
65
|
+
* reacquires the shared permit and its own bounded timeout. Typed SDK status and decoding
|
|
66
|
+
* failures still propagate immediately; a read is always safe to replay because it never
|
|
67
|
+
* changes vault state.
|
|
68
|
+
*/
|
|
69
|
+
export const runBaoRead = <A, E>(
|
|
70
|
+
operation: Effect.Effect<A, E, OpenBaoOpContext>,
|
|
71
|
+
timeout: Duration.Input = BAO_TIMEOUT,
|
|
72
|
+
): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
73
|
+
retryTransport(runBao(operation, timeout));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pre-PR `baoCall()` retried every call, writes included; that bound was lost when the
|
|
77
|
+
* distilled SDK path landed with `Retry.none` on `runBao`. Restore it for the writes where
|
|
78
|
+
* replaying is safe: a FULL-REPLACE, idempotent write (an OpenBao policy PUT, an AppRole
|
|
79
|
+
* write) sends the complete desired state every time, so retrying an uncertain transport
|
|
80
|
+
* failure converges on the same result rather than compounding a partial change. A create
|
|
81
|
+
* that mints a new identifier, or any operation whose second attempt could do something the
|
|
82
|
+
* first did not, must keep calling `runBao` directly and make one attempt.
|
|
83
|
+
*/
|
|
84
|
+
export const runBaoWrite = <A, E>(
|
|
85
|
+
operation: Effect.Effect<A, E, OpenBaoOpContext>,
|
|
86
|
+
timeout: Duration.Input = BAO_TIMEOUT,
|
|
87
|
+
): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
88
|
+
retryTransport(runBao(operation, timeout));
|
|
@@ -48,10 +48,17 @@ const typed = (body: Stored): Stored => {
|
|
|
48
48
|
return out;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Every role engine, JWT auth config and MFA login enforcement.
|
|
53
|
+
* ★ AppRole preserves omitted knobs and supplies defaults on create (OpenBao 2.6.2
|
|
54
|
+
* path_role.go:1688-1721); this fixture must not turn a correct omitted SDK field into drift.
|
|
55
|
+
*/
|
|
52
56
|
export const wireRoles = (): Store =>
|
|
53
57
|
store<Stored>(
|
|
54
|
-
(
|
|
58
|
+
(path, body, before) =>
|
|
59
|
+
path.startsWith('auth/approle/role/')
|
|
60
|
+
? { bind_secret_id: true, secret_id_num_uses: 0, ...before, ...typed(body) }
|
|
61
|
+
: typed(body),
|
|
55
62
|
(path) => keyOf(path),
|
|
56
63
|
);
|
|
57
64
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import { runFailure, withFake } from './fake-bao.ts';
|
|
4
|
+
import { aclPolicies, fakePolicyProviders } from './fake-engines.ts';
|
|
5
|
+
import { withFakeStack, writesOf } from './fake-stack.ts';
|
|
6
|
+
import { BaoPolicy } from './policy.ts';
|
|
7
|
+
import { judgePolicyRename } from './policy-identity.ts';
|
|
8
|
+
import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
|
|
9
|
+
|
|
10
|
+
for (const [status, tag] of [
|
|
11
|
+
[401, 'Unauthorized'],
|
|
12
|
+
[403, 'Forbidden'],
|
|
13
|
+
[503, 'ServiceUnavailable'],
|
|
14
|
+
] as const) {
|
|
15
|
+
test(`SDK policy ${status} failures propagate on every verb without retry`, async () => {
|
|
16
|
+
await withFake(
|
|
17
|
+
() => ({ status, json: { errors: ['refused'] } }),
|
|
18
|
+
async (bao) => {
|
|
19
|
+
const env = { BAO_ADDR: bao.address };
|
|
20
|
+
for (const op of [
|
|
21
|
+
Effect.asVoid(readPolicy('test')),
|
|
22
|
+
writePolicy('test', 'path "test" {}'),
|
|
23
|
+
deletePolicy('test'),
|
|
24
|
+
]) {
|
|
25
|
+
expect(await runFailure(env, op)).toMatchObject({ _tag: tag });
|
|
26
|
+
}
|
|
27
|
+
expect(bao.seen.map(({ method }) => method)).toEqual(['GET', 'POST', 'DELETE']);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('SDK policy refuses null, non-string, and missing policy text on a successful read', async () => {
|
|
34
|
+
for (const data of [null, {}, { policy: 42 }]) {
|
|
35
|
+
await withFake(
|
|
36
|
+
() => ({ status: 200, json: { data } }),
|
|
37
|
+
async (bao) => {
|
|
38
|
+
const error = await runFailure({ BAO_ADDR: bao.address }, readPolicy('test'));
|
|
39
|
+
expect(String(error)).toContain('no data.policy');
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rename checks preserve an occupied empty policy and propagate denied SDK reads', async () => {
|
|
46
|
+
await withFake(
|
|
47
|
+
() => ({ status: 200, json: { data: { policy: '' } } }),
|
|
48
|
+
async (bao) => {
|
|
49
|
+
await expect(
|
|
50
|
+
runFailure(
|
|
51
|
+
{ BAO_ADDR: bao.address },
|
|
52
|
+
judgePolicyRename({ name: 'old' }, { name: 'new' }, undefined),
|
|
53
|
+
),
|
|
54
|
+
).rejects.toThrow('already exists');
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
await withFake(
|
|
58
|
+
() => ({ status: 403, json: { errors: ['refused'] } }),
|
|
59
|
+
async (bao) => {
|
|
60
|
+
expect(
|
|
61
|
+
await runFailure(
|
|
62
|
+
{ BAO_ADDR: bao.address },
|
|
63
|
+
judgePolicyRename({ name: 'old' }, { name: 'new' }, undefined),
|
|
64
|
+
),
|
|
65
|
+
).toMatchObject({ _tag: 'Forbidden' });
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('policy adoption and next deploy write nothing, while live drift writes one SDK POST', async () => {
|
|
71
|
+
const store = aclPolicies();
|
|
72
|
+
const path = 'sys/policies/acl/test';
|
|
73
|
+
store.live.set(path, {
|
|
74
|
+
name: 'test',
|
|
75
|
+
policy: 'path "/policies/test/a.hcl" {\n capabilities = ["read"]\n}',
|
|
76
|
+
});
|
|
77
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
78
|
+
const body = BaoPolicy('Policy', { name: 'test', fragments: '/policies/test' });
|
|
79
|
+
expect(await stack.deploy(body, { adopt: true })).toEqual({ Policy: 'adopted' });
|
|
80
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
81
|
+
expect(await stack.deploy(body)).toEqual({ Policy: 'noop' });
|
|
82
|
+
store.live.set(path, { name: 'test', policy: 'path "drift" {}' });
|
|
83
|
+
bao.seen.length = 0;
|
|
84
|
+
expect(await stack.deploy(body)).toEqual({ Policy: 'update' });
|
|
85
|
+
expect(writesOf(bao.seen)).toEqual(['POST /v1/sys/policies/acl/test']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a real empty policy is an existing unowned object, never a first-time create', async () => {
|
|
90
|
+
const store = aclPolicies();
|
|
91
|
+
store.live.set('sys/policies/acl/test', { name: 'test', policy: '' });
|
|
92
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
93
|
+
await expect(
|
|
94
|
+
stack.deploy(BaoPolicy('Policy', { name: 'test', fragments: '/policies/test' })),
|
|
95
|
+
).rejects.toThrow('not owned');
|
|
96
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { policyExists, policyPath } from './policy-wire.ts';
|
|
2
|
+
import { declaredString, judgeMoveWith, policyKey } from './rename.ts';
|
|
3
|
+
import { nameIdentity } from './rename-identity.ts';
|
|
4
|
+
|
|
5
|
+
/** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
|
|
6
|
+
export const policyIdentity = nameIdentity<{ readonly name: string }>(
|
|
7
|
+
'Bao.Policy',
|
|
8
|
+
policyPath,
|
|
9
|
+
policyKey,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** The occupied-name guard must use the same SDK and refusal semantics as every other read. */
|
|
13
|
+
export const judgePolicyRename = (
|
|
14
|
+
olds: unknown,
|
|
15
|
+
news: unknown,
|
|
16
|
+
output: { readonly name: string } | undefined,
|
|
17
|
+
) =>
|
|
18
|
+
judgeMoveWith(
|
|
19
|
+
'Bao.Policy',
|
|
20
|
+
output === undefined ? declaredString(olds, 'name') : output.name,
|
|
21
|
+
declaredString(news, 'name'),
|
|
22
|
+
policyExists,
|
|
23
|
+
policyKey,
|
|
24
|
+
);
|
|
@@ -38,7 +38,7 @@ describe('Bao.Policy rename, through the engine', () => {
|
|
|
38
38
|
Policy: 'replace',
|
|
39
39
|
});
|
|
40
40
|
assert.deepEqual(writes(bao.seen), [
|
|
41
|
-
'
|
|
41
|
+
'POST /v1/sys/policies/acl/app-new',
|
|
42
42
|
'DELETE /v1/sys/policies/acl/app-old',
|
|
43
43
|
]);
|
|
44
44
|
assert.deepEqual(live(), ['app-new']);
|
|
@@ -101,7 +101,7 @@ describe('Bao.Policy rename, through the engine', () => {
|
|
|
101
101
|
stack.deploy(body('/policies/two')),
|
|
102
102
|
/identity moved from app-1 to app-2/,
|
|
103
103
|
);
|
|
104
|
-
assert.deepEqual(writes(bao.seen), ['
|
|
104
|
+
assert.deepEqual(writes(bao.seen), ['POST /v1/sys/policies/acl/upstream']);
|
|
105
105
|
assert.deepEqual(await stack.deploy(body('/policies/two')), {
|
|
106
106
|
Policy: 'replace',
|
|
107
107
|
Upstream: 'noop',
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import { describe, it } from 'node:test';
|
|
8
|
-
import {
|
|
8
|
+
import { Forbidden } from '@distilled.cloud/openbao/Errors';
|
|
9
9
|
import { sha256 } from './digest.ts';
|
|
10
10
|
import { run, runFailure, withFake } from './fake-bao.ts';
|
|
11
11
|
import { aclPolicies } from './fake-engines.ts';
|
|
@@ -19,7 +19,7 @@ const ASSEMBLED = 'path "kv/data/a" {\n capabilities = ["read"]\n}\n' + 'path "
|
|
|
19
19
|
describe('policy wire', () => {
|
|
20
20
|
it('reads a policy that was never written as absent', async () => {
|
|
21
21
|
await withFake(aclPolicies(), async (bao) => {
|
|
22
|
-
assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')),
|
|
22
|
+
assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), undefined);
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -29,10 +29,11 @@ describe('policy wire', () => {
|
|
|
29
29
|
await withFake(aclPolicies(), async (bao) => {
|
|
30
30
|
const env = { BAO_ADDR: bao.address };
|
|
31
31
|
await run(env, writePolicy('app-llm', ASSEMBLED));
|
|
32
|
-
assert.equal(bao.seen[0]?.method, '
|
|
32
|
+
assert.equal(bao.seen[0]?.method, 'POST');
|
|
33
33
|
assert.equal(bao.seen[0]?.path, `${PREFIX}app-llm`);
|
|
34
34
|
assert.deepEqual(JSON.parse(bao.seen[0]?.body ?? ''), { policy: ASSEMBLED });
|
|
35
35
|
const live = await run(env, readPolicy('app-llm'));
|
|
36
|
+
assert.ok(live !== undefined);
|
|
36
37
|
assert.notEqual(live, ASSEMBLED, 'the fake strips the newline, as OpenBao does');
|
|
37
38
|
assert.equal(sha256(live), sha256(ASSEMBLED));
|
|
38
39
|
});
|
|
@@ -43,8 +44,7 @@ describe('policy wire', () => {
|
|
|
43
44
|
() => ({ json: { errors: ['permission denied'] }, status: 403 }),
|
|
44
45
|
async (bao) => {
|
|
45
46
|
const error = await runFailure({ BAO_ADDR: bao.address }, readPolicy('homeflare-admin'));
|
|
46
|
-
assert.ok(error instanceof
|
|
47
|
-
assert.equal(error.status, 403);
|
|
47
|
+
assert.ok(error instanceof Forbidden);
|
|
48
48
|
},
|
|
49
49
|
);
|
|
50
50
|
});
|
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
* The three OpenBao calls Bao.Policy makes — read, write and delete of `sys/policies/acl/<name>`.
|
|
3
3
|
* Split from policy.ts so the trailing-newline round trip can be tested against a fake server.
|
|
4
4
|
*/
|
|
5
|
+
import {
|
|
6
|
+
policiesDeleteAclPolicy,
|
|
7
|
+
policiesReadAclPolicy,
|
|
8
|
+
policiesWriteAclPolicy,
|
|
9
|
+
} from '@distilled.cloud/openbao/policies';
|
|
5
10
|
import * as Effect from 'effect/Effect';
|
|
6
|
-
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
7
11
|
import { BaoError } from './bao-status.ts';
|
|
12
|
+
import { runBao, runBaoRead, runBaoWrite } from './distilled.ts';
|
|
8
13
|
|
|
9
14
|
/** The endpoint the CLI's policy commands used — openbao v2.6.2 api/sys_policy.go:60, :100, :122. */
|
|
10
15
|
export const policyPath = (name: string) => `sys/policies/acl/${name}`;
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
|
-
* The live policy text, or
|
|
18
|
+
* The live policy text, or undefined only when the SDK reports PolicyNotFound. Other errors FAIL.
|
|
14
19
|
*
|
|
15
20
|
* 🔴 THE ABSENCE WORDING WAS THE POLICY COMMAND'S OWN, AND ASSUMING OTHERWISE MADE A NEW POLICY
|
|
16
21
|
* UNCREATABLE. `No value found at` was measured for `bao read`; `bao policy read` never printed
|
|
@@ -33,21 +38,36 @@ export const policyPath = (name: string) => `sys/policies/acl/${name}`;
|
|
|
33
38
|
* ("no policy found in response", api/sys_policy.go:81-85).
|
|
34
39
|
*/
|
|
35
40
|
export const readPolicy = (name: string) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
runBaoRead(policiesReadAclPolicy({ name })).pipe(
|
|
42
|
+
Effect.flatMap((data) => {
|
|
43
|
+
const text = data['policy'];
|
|
44
|
+
return typeof text === 'string'
|
|
45
|
+
? Effect.succeed(text)
|
|
46
|
+
: Effect.fail(
|
|
47
|
+
new BaoError(200, 'GET', policyPath(name), ['no data.policy in the response']),
|
|
48
|
+
);
|
|
49
|
+
}),
|
|
50
|
+
Effect.catchTag('PolicyNotFound', () => Effect.succeed(undefined)),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Even a live empty policy occupies its name; rename guards must not overwrite it. */
|
|
54
|
+
export const policyExists = (name: string) =>
|
|
55
|
+
Effect.map(readPolicy(name), (policy) => policy !== undefined);
|
|
43
56
|
|
|
44
57
|
/**
|
|
45
58
|
* ★ THE HCL TRAVELS IN THE REQUEST BODY (`{"policy": …}`, api/sys_policy.go:96-101). The CLI path
|
|
46
59
|
* needed a scoped temp file because passing HCL on argv would have put every grant in the process
|
|
47
60
|
* list; a JSON body never reaches argv, so the temp file is gone with it.
|
|
61
|
+
* ★ A FULL-REPLACE WRITE: the whole policy text is sent every time, so it is safe to retry a
|
|
62
|
+
* transport failure through `runBaoWrite` — a replayed attempt converges on the same policy
|
|
63
|
+
* rather than compounding a partial one.
|
|
48
64
|
*/
|
|
49
65
|
export const writePolicy = (name: string, hcl: string) =>
|
|
50
|
-
|
|
66
|
+
runBaoWrite(policiesWriteAclPolicy({ name, policy: hcl })).pipe(Effect.asVoid);
|
|
51
67
|
|
|
52
68
|
/** Deleting a policy that is already gone answers success (handlePoliciesDelete returns nothing). */
|
|
53
|
-
export const deletePolicy = (name: string) =>
|
|
69
|
+
export const deletePolicy = (name: string) =>
|
|
70
|
+
runBao(policiesDeleteAclPolicy({ name })).pipe(
|
|
71
|
+
Effect.catchTag('NotFound', () => Effect.void),
|
|
72
|
+
Effect.asVoid,
|
|
73
|
+
);
|