@homeflare/alchemy 0.37.4 → 0.37.6
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/linux/directory-read.d.ts +14 -0
- package/dist/linux/directory-read.d.ts.map +1 -0
- package/dist/linux/directory.d.ts.map +1 -1
- package/dist/linux/index.js +22 -6
- package/dist/linux/index.js.map +5 -4
- package/dist/litellm/index.js +12 -3
- package/dist/litellm/index.js.map +3 -3
- package/dist/litellm/operations.d.ts.map +1 -1
- 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/index.js +2 -2
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/linux/directory-read.ts +24 -0
- package/src/linux/directory.test.ts +20 -0
- package/src/linux/directory.ts +21 -11
- package/src/litellm/operations.ts +31 -4
- package/src/litellm/pass-through-fetch-client.test.ts +40 -0
- 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/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
|
@@ -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
|
+
);
|
package/src/openbao/policy.ts
CHANGED
|
@@ -10,9 +10,9 @@ import { ownedRead } from '../ownership/probe.ts';
|
|
|
10
10
|
import { provingResumes } from '../ownership/resume.ts';
|
|
11
11
|
import { sha256 } from './digest.ts';
|
|
12
12
|
import { isEmptyAssembly } from './policy-assembly.ts';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { guardRename
|
|
13
|
+
import { judgePolicyRename, policyIdentity } from './policy-identity.ts';
|
|
14
|
+
import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
|
|
15
|
+
import { guardRename } from './rename-identity.ts';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* An OpenBao ACL policy, assembled from the HCL fragments that declare it.
|
|
@@ -103,9 +103,6 @@ export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
|
|
|
103
103
|
defaultRemovalPolicy: 'retain',
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
/** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
|
|
107
|
-
const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
|
|
108
|
-
|
|
109
106
|
/** `path "..."` grant count — the same number the apply script prints. */
|
|
110
107
|
const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
|
|
111
108
|
|
|
@@ -140,12 +137,12 @@ export const BaoPolicyProvider = () =>
|
|
|
140
137
|
*/
|
|
141
138
|
list: () => Effect.succeed([]),
|
|
142
139
|
|
|
143
|
-
// ★
|
|
140
|
+
// ★ Only undefined means absent (a typed 404); even a live empty policy occupies its name.
|
|
144
141
|
// ⛔ With no attributes in state, a live policy is `Unowned` unless it is the one our own
|
|
145
142
|
// interrupted create wrote: these fragments, byte for byte (ownership/probe.ts).
|
|
146
143
|
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
147
144
|
const live = yield* readPolicy(olds.name);
|
|
148
|
-
const found = live
|
|
145
|
+
const found = live === undefined ? undefined : attributesOf(olds.name, live, 0);
|
|
149
146
|
const ours = Effect.map(
|
|
150
147
|
assemble(olds.fragments),
|
|
151
148
|
(a) => sha256(a.joined) === found?.digest,
|
|
@@ -170,7 +167,7 @@ export const BaoPolicyProvider = () =>
|
|
|
170
167
|
* delete, so a role outside this graph that names the old policy must move in the same
|
|
171
168
|
* PR (REPLACE.md).
|
|
172
169
|
*/
|
|
173
|
-
const move = yield*
|
|
170
|
+
const move = yield* judgePolicyRename(olds, news, output);
|
|
174
171
|
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
175
172
|
if (output === undefined) return undefined;
|
|
176
173
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
@@ -180,7 +177,7 @@ export const BaoPolicyProvider = () =>
|
|
|
180
177
|
if (!isResolved(news)) return undefined;
|
|
181
178
|
const { joined } = yield* assemble(news.fragments);
|
|
182
179
|
const live = yield* readPolicy(news.name);
|
|
183
|
-
return live
|
|
180
|
+
return live !== undefined && sha256(live) === sha256(joined)
|
|
184
181
|
? ({ action: 'noop' } as const)
|
|
185
182
|
: ({ action: 'update' } as const);
|
|
186
183
|
}),
|
|
@@ -188,7 +185,7 @@ export const BaoPolicyProvider = () =>
|
|
|
188
185
|
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
189
186
|
const name = news.name;
|
|
190
187
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
191
|
-
yield* guardRename(
|
|
188
|
+
yield* guardRename(policyIdentity, news, output);
|
|
192
189
|
const { joined, parts } = yield* assemble(news.fragments);
|
|
193
190
|
/**
|
|
194
191
|
* ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
|
|
@@ -218,9 +215,9 @@ export const BaoPolicyProvider = () =>
|
|
|
218
215
|
* sibling PVE provider does exactly this in pveOperations.reconcile.
|
|
219
216
|
*/
|
|
220
217
|
const current = yield* readPolicy(name);
|
|
221
|
-
if (current
|
|
218
|
+
if (current !== undefined)
|
|
222
219
|
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
|
|
223
|
-
if (current
|
|
220
|
+
if (current !== undefined && sha256(current) === sha256(joined)) {
|
|
224
221
|
return attributesOf(name, joined, parts);
|
|
225
222
|
}
|
|
226
223
|
// ⚠️ In the request body, never on argv — see `writePolicy` for why the temp file went.
|
package/src/openbao/rename.ts
CHANGED
|
@@ -127,12 +127,28 @@ export const judgeMove = (
|
|
|
127
127
|
declared: string | undefined,
|
|
128
128
|
pathOf: (identity: string) => string,
|
|
129
129
|
key: (identity: string) => string = exact,
|
|
130
|
+
) =>
|
|
131
|
+
judgeMoveWith(
|
|
132
|
+
family,
|
|
133
|
+
tried,
|
|
134
|
+
declared,
|
|
135
|
+
(identity) => Effect.map(baoRead(pathOf(identity)), (value) => value !== undefined),
|
|
136
|
+
key,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** SDK families supply their own typed read so a rename cannot fall back to raw HTTP. */
|
|
140
|
+
export const judgeMoveWith = <E, R>(
|
|
141
|
+
family: string,
|
|
142
|
+
tried: string | undefined,
|
|
143
|
+
declared: string | undefined,
|
|
144
|
+
occupied: (identity: string) => Effect.Effect<boolean, E, R>,
|
|
145
|
+
key: (identity: string) => string = exact,
|
|
130
146
|
) =>
|
|
131
147
|
Effect.gen(function* () {
|
|
132
148
|
if (tried === undefined || declared === undefined || key(tried) === key(declared)) {
|
|
133
149
|
return undefined;
|
|
134
150
|
}
|
|
135
|
-
if ((yield*
|
|
151
|
+
if (!(yield* occupied(declared))) return { from: tried, to: declared };
|
|
136
152
|
return yield* Effect.die(
|
|
137
153
|
new Error(
|
|
138
154
|
`${family}: ${tried} → ${declared} would land on an object that already exists at ` +
|
|
@@ -139,6 +139,30 @@ describe('only the measured "interface does not exist" 400 means absent', () =>
|
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* ⛔ THE RETAG REGRESSION'S OWN TEST — RED-TEAM FINDING, 2026-09-25: `@distilled.cloud/proxmox`
|
|
144
|
+
* retagged this exact 400 from the generic `ParameterVerificationFailed` to a specific
|
|
145
|
+
* `NetworkInterfaceNotFound` (`nodes.ts`), and `readInterfaceOrFail`'s `catchTag` still named
|
|
146
|
+
* the OLD tag — so on an already-adopted row (this is `diff`'s and `verify --all`'s `read`'s
|
|
147
|
+
* `output !== undefined` branch, the non-folding one) the absence signal went UNCAUGHT and
|
|
148
|
+
* threw straight through `engine.verify`, instead of resolving a `diff`/`missing` report.
|
|
149
|
+
* Proven by temporarily reverting the `catchTag` to `'ParameterVerificationFailed'`: only this
|
|
150
|
+
* test fails, with an unhandled `NetworkInterfaceNotFound`.
|
|
151
|
+
*/
|
|
152
|
+
test('an already-adopted interface whose live GET now answers genuinely absent resolves via diff as missing, never throws', async () => {
|
|
153
|
+
const fake = clusterOverInterface('n2', 'vmbr0', live);
|
|
154
|
+
await withoutBao(async () => {
|
|
155
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
156
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
|
|
157
|
+
fake.setAnswer('genuinely-absent');
|
|
158
|
+
const report = await engine.verify(declare(), { all: true });
|
|
159
|
+
// ⛔ `update` IS THE RIGHT ANSWER HERE, NOT A THROW: this resource's `diff` treats a
|
|
160
|
+
// vanished interface as something `reconcile` recreates (node-network.ts's `diff`,
|
|
161
|
+
// `live === undefined` branch), the same as any other drifted-away row.
|
|
162
|
+
expect(report.rows[0]).toMatchObject({ diff: 'update' });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
142
166
|
/**
|
|
143
167
|
* ★ WHY `read` FOLDS AT ALL, SPELLED OUT ON A BRAND NEW DECLARATION. Plan.ts's cold-start
|
|
144
168
|
* adoption probe (no prior Alchemy state for this declaration) calls `provider.read` with no
|
|
@@ -156,14 +156,17 @@ const attributesOf = (
|
|
|
156
156
|
* `{"errors":{"iface":"interface does not exist"},"data":null,"message":"Parameter verification
|
|
157
157
|
* failed.\n"}` at HTTP 400 — a THIRD shape, different from every other migrated family's
|
|
158
158
|
* measured 500. `@distilled.cloud/proxmox` types this as a genuine typed error,
|
|
159
|
-
* `
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
159
|
+
* `NetworkInterfaceNotFound` (`nodes.ts`, generated from `patches/nodes/network-errors.json`),
|
|
160
|
+
* attached ONLY to `getNodeNetwork`'s error union and matched by the SDK's own
|
|
161
|
+
* `errorEnvelope`/`matchTypedError` against the EXACT sole-field body `errors.iface ===
|
|
162
|
+
* "interface does not exist"` — the precision the code here used to check by hand
|
|
163
|
+
* (`error.errors['iface'] === '…'`) is now the SDK's own matcher contract, not this file's.
|
|
164
|
+
* Any 400 that fails that exact match (a different field, a different message, more than one
|
|
165
|
+
* field) decodes as the generic `ParameterVerificationFailed` instead, never this tag.
|
|
166
|
+
* ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason
|
|
167
|
+
* (`ParameterVerificationFailed`), a permission gap, a network blip, or any other failure is
|
|
168
|
+
* never read as "this interface is not there" — `diff` calls this directly and propagates
|
|
169
|
+
* every one of them loudly.
|
|
167
170
|
*/
|
|
168
171
|
export const readInterfaceOrFail = (props: NodeNetworkProps) =>
|
|
169
172
|
readOrUnreadable(
|
|
@@ -172,13 +175,7 @@ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
|
|
|
172
175
|
'read',
|
|
173
176
|
false,
|
|
174
177
|
nodes.getNodeNetwork({ iface: props.iface, node: props.node }),
|
|
175
|
-
).pipe(
|
|
176
|
-
Effect.catchTag('ParameterVerificationFailed', (error) =>
|
|
177
|
-
error.errors['iface'] === 'interface does not exist'
|
|
178
|
-
? Effect.succeed(undefined)
|
|
179
|
-
: Effect.fail(error),
|
|
180
|
-
),
|
|
181
|
-
),
|
|
178
|
+
).pipe(Effect.catchTag('NetworkInterfaceNotFound', () => Effect.succeed(undefined))),
|
|
182
179
|
).pipe(
|
|
183
180
|
Effect.map((live) => {
|
|
184
181
|
if (live === UNREADABLE) return UNREADABLE;
|
|
@@ -198,8 +195,9 @@ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
|
|
|
198
195
|
* interrupted-create recovery, and Apply.ts's delete recovery, all call `read` with
|
|
199
196
|
* `output: undefined` and NO catch of their own (`Plan.ts` v2.0.0-beta.79 ~line 1308) — and
|
|
200
197
|
* Plan.ts fails the WHOLE PLAN, every other resource included, if that call throws. A malformed
|
|
201
|
-
* `iface` in ONE brand-new declaration (a
|
|
202
|
-
* absence
|
|
198
|
+
* `iface` in ONE brand-new declaration (a `ParameterVerificationFailed`, DIFFERENT from the
|
|
199
|
+
* `NetworkInterfaceNotFound` absence tag) would abort `bun run plan` for the entire stack under
|
|
200
|
+
* the precise-only design,
|
|
203
201
|
* where every other migrated family instead defers that same malformed declaration to a scoped,
|
|
204
202
|
* loud refusal at apply. This restores that same fold for those three call sites.
|
|
205
203
|
*/
|