@homeflare/alchemy 0.37.5 → 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/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/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
|
@@ -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
|
*/
|