@homeflare/alchemy 0.31.0 → 0.31.1
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/proxmox/acl-wire.d.ts +26 -0
- package/dist/proxmox/acl-wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +28 -26
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +48 -89
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +2 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/client.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +27 -0
- package/dist/proxmox/credential-errors.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +0 -38
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/distilled-guard.d.ts +31 -0
- package/dist/proxmox/distilled-guard.d.ts.map +1 -0
- package/dist/proxmox/distilled-pve.d.ts +11 -4
- package/dist/proxmox/distilled-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts +29 -0
- package/dist/proxmox/group-wire.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +20 -24
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +725 -509
- package/dist/proxmox/index.js.map +40 -33
- package/dist/proxmox/lease-cache.d.ts +1 -1
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +50 -0
- package/dist/proxmox/mint.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +2 -2
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +4 -4
- package/dist/proxmox/role-wire.d.ts +28 -0
- package/dist/proxmox/role-wire.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +36 -52
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +50 -0
- package/dist/proxmox/unreadable-read.d.ts.map +1 -0
- package/dist/proxmox/user-wire.d.ts +18 -0
- package/dist/proxmox/user-wire.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +25 -32
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +2 -2
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +29 -82
- package/src/proxmox/adopt-noop.test.ts +18 -0
- package/src/proxmox/api-token-adopt.test.ts +28 -12
- package/src/proxmox/api-token-form.ts +50 -93
- package/src/proxmox/api-token.ts +149 -135
- package/src/proxmox/client.ts +2 -7
- package/src/proxmox/constraints-forms.test.ts +7 -27
- package/src/proxmox/credential-errors.ts +33 -0
- package/src/proxmox/credentials.test.ts +29 -3
- package/src/proxmox/credentials.ts +4 -134
- package/src/proxmox/distilled-guard.ts +42 -0
- package/src/proxmox/distilled-pve.ts +15 -4
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -4
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/group-wire.ts +63 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +135 -105
- package/src/proxmox/lease-cache.ts +2 -1
- package/src/proxmox/lxc-lifecycle.ts +2 -1
- package/src/proxmox/mint.ts +158 -0
- package/src/proxmox/network-apply.ts +1 -1
- package/src/proxmox/provision-cli-fake.ts +11 -0
- package/src/proxmox/provision-declare.test.ts +6 -1
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +147 -106
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +112 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +132 -129
|
@@ -28,13 +28,6 @@
|
|
|
28
28
|
* secret that reached an attribute would outlive its 300s lease by months, in four places.
|
|
29
29
|
* Nothing here returns it to a resource; it is used to build a header and then dropped.
|
|
30
30
|
*/
|
|
31
|
-
import * as Effect from 'effect/Effect';
|
|
32
|
-
import * as Option from 'effect/Option';
|
|
33
|
-
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
34
|
-
import * as Headers from 'effect/unstable/http/Headers';
|
|
35
|
-
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
36
|
-
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
37
|
-
|
|
38
31
|
/**
|
|
39
32
|
* ★ NO SESSION, ON PURPOSE, AND THIS IS WHAT UNBLOCKED `wip/bao-policy`.
|
|
40
33
|
* `CommandExecutor.run` requires a `ScopedPlanStatusSession`, and Alchemy gives one to
|
|
@@ -125,130 +118,7 @@ export type PveCredential = {
|
|
|
125
118
|
/** The environment `mint` resolves OpenBao from — `process.env` unless a test passes its own. */
|
|
126
119
|
export type BaoEnvironment = Readonly<Record<string, string | undefined>>;
|
|
127
120
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Mint one credential for `role`: `GET /v1/<mount>/creds/<role>` on OpenBao's HTTP API.
|
|
134
|
-
*
|
|
135
|
-
* 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
|
|
136
|
-
* client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
|
|
137
|
-
* learned twice what deciding by stderr costs — every failure read as absent, and two commands
|
|
138
|
-
* said "absent" in different words — so this reads STATUS CODES: 2xx is a credential, and 403
|
|
139
|
-
* (denied), 404 (no such role or mount — a mint has no "absent"), 503 (sealed) all fail.
|
|
140
|
-
* ★ THE ENVIRONMENT CONTRACT IS STILL THE CLI's, READ THE CLI's WAY, which keeps the case above
|
|
141
|
-
* true (openbao v2.6.2 api/client.go:34-66, 370, 503-507, 761-762, 776-785): BAO_AGENT_ADDR over
|
|
142
|
-
* BAO_ADDR over https://127.0.0.1:8200; BAO_NAMESPACE as X-Vault-Namespace; BAO_TOKEN as
|
|
143
|
-
* X-Vault-Token; X-Vault-Request always. <estate>/openbao/src/bao-address.ts has the long form.
|
|
144
|
-
* ★ AGENT MODE IS NO BAO_TOKEN AND NO TOKEN HEADER. An Agent or Proxy listener with
|
|
145
|
-
* `api_proxy { use_auto_auth_token = true }` (or the older `cache { … }`) substitutes its
|
|
146
|
-
* auto-auth token exactly when a request carries none — command/agentproxyshared/cache/handler.go
|
|
147
|
-
* :29-38, command/agent.go:517-541, command/agent/config/config.go:288-292, and
|
|
148
|
-
* website/content/docs/agent-and-proxy/agent/apiproxy.mdx:57-62. The CLI's token-file fallback
|
|
149
|
-
* is deliberately not read. ⚠️ So an unset BAO_TOKEN aimed at a SERVER rather than an agent is a
|
|
150
|
-
* 403, which reads like a missing grant rather than a missing variable.
|
|
151
|
-
* ★ UNIX SOCKETS: `unix:///path` is plain HTTP to `localhost` over that socket, as the CLI does it
|
|
152
|
-
* (api/client.go:654-667). Bun's fetch takes a `unix` option; `FetchHttpClient.RequestInit` is
|
|
153
|
-
* Effect's documented way to pass fetch options, read from the calling fiber (effect
|
|
154
|
-
* FetchHttpClient.ts:59-72). Off Bun it REFUSES, rather than reach http://localhost:80.
|
|
155
|
-
*/
|
|
156
|
-
export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = process.env) =>
|
|
157
|
-
Effect.gen(function* () {
|
|
158
|
-
const path = `${target.mount}/creds/${role}`;
|
|
159
|
-
// ⛔ METHOD, PATH AND OpenBao's `errors` ONLY — never a header (the token), never a success
|
|
160
|
-
// body (the credential is IN it, so echoing it would print the secret into a log).
|
|
161
|
-
const refuse = (status: number, detail: string) =>
|
|
162
|
-
new Error(`OpenBao GET /v1/${path} -> ${status === 0 ? 'no response' : status}: ${detail}`);
|
|
163
|
-
const address =
|
|
164
|
-
baoVariable(env, 'AGENT_ADDR') || baoVariable(env, 'ADDR') || 'https://127.0.0.1:8200';
|
|
165
|
-
const socket = address.startsWith('unix://') ? address.slice('unix://'.length) : undefined;
|
|
166
|
-
const base = socket === undefined ? address.replace(/\/+$/, '') : 'http://localhost';
|
|
167
|
-
const headers: Record<string, string> = { 'X-Vault-Request': 'true' };
|
|
168
|
-
const namespace = baoVariable(env, 'NAMESPACE');
|
|
169
|
-
if (namespace !== '') headers['X-Vault-Namespace'] = namespace;
|
|
170
|
-
const token = baoVariable(env, 'TOKEN');
|
|
171
|
-
if (token !== '') headers['X-Vault-Token'] = token;
|
|
172
|
-
if (socket !== undefined && !('Bun' in globalThis)) {
|
|
173
|
-
return yield* Effect.fail(refuse(0, 'a unix:// address needs Bun fetch; this is not Bun'));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const client = yield* HttpClient.HttpClient;
|
|
177
|
-
// ⛔ Effect records request headers on the client span, and its default redaction list does
|
|
178
|
-
// not name x-vault-token (effect Headers.ts:753-762). It is added for this call.
|
|
179
|
-
const redacted = yield* Headers.CurrentRedactedNames;
|
|
180
|
-
const defaults = Option.getOrElse(
|
|
181
|
-
yield* Effect.serviceOption(FetchHttpClient.RequestInit),
|
|
182
|
-
() => ({}),
|
|
183
|
-
);
|
|
184
|
-
const request = HttpClientRequest.get(`${base}/v1/${path}`).pipe(
|
|
185
|
-
HttpClientRequest.setHeaders(headers),
|
|
186
|
-
);
|
|
187
|
-
const exchange = client.execute(request).pipe(
|
|
188
|
-
Effect.flatMap((response) =>
|
|
189
|
-
Effect.map(response.text, (text) => ({ status: response.status, text })),
|
|
190
|
-
),
|
|
191
|
-
// ⚠️ The CLI's default client timeout (api/client.go:324) — a hung agent must not hang a plan.
|
|
192
|
-
Effect.timeout('60 seconds'),
|
|
193
|
-
Effect.provideService(Headers.CurrentRedactedNames, [...redacted, 'x-vault-token']),
|
|
194
|
-
);
|
|
195
|
-
// ⚠️ `| undefined` under exactOptionalPropertyTypes: `socket` is narrowed to a string
|
|
196
|
-
// only on the branch below, so the object literal genuinely carries
|
|
197
|
-
// `unix: string | undefined` and the type must say so. The alternative — building
|
|
198
|
-
// the key conditionally — hides that this request is the unix-socket path.
|
|
199
|
-
const init: RequestInit & { unix?: string | undefined } = { ...defaults, unix: socket };
|
|
200
|
-
const { status, text } = yield* (
|
|
201
|
-
socket === undefined
|
|
202
|
-
? exchange
|
|
203
|
-
: Effect.provideService(exchange, FetchHttpClient.RequestInit, init)
|
|
204
|
-
).pipe(
|
|
205
|
-
// ⛔ `.message` only — the error object holds the request, and the request holds the token.
|
|
206
|
-
Effect.mapError((cause) => refuse(0, cause.message)),
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
let parsed:
|
|
210
|
-
| {
|
|
211
|
-
lease_duration?: unknown;
|
|
212
|
-
data?: { token_id?: unknown; secret?: unknown };
|
|
213
|
-
errors?: unknown;
|
|
214
|
-
}
|
|
215
|
-
| undefined;
|
|
216
|
-
try {
|
|
217
|
-
parsed = JSON.parse(text) as typeof parsed;
|
|
218
|
-
} catch {
|
|
219
|
-
parsed = undefined;
|
|
220
|
-
}
|
|
221
|
-
if (status < 200 || status >= 300) {
|
|
222
|
-
const errors = parsed?.errors;
|
|
223
|
-
const detail = Array.isArray(errors)
|
|
224
|
-
? errors.map(String).join('; ')
|
|
225
|
-
: 'not an OpenBao error body';
|
|
226
|
-
return yield* Effect.fail(refuse(status, detail || '(no errors given)'));
|
|
227
|
-
}
|
|
228
|
-
const tokenId = parsed?.data?.token_id;
|
|
229
|
-
const secret = parsed?.data?.secret;
|
|
230
|
-
if (typeof tokenId !== 'string' || typeof secret !== 'string') {
|
|
231
|
-
// ⛔ REFUSE RATHER THAN RETURN A HALF CREDENTIAL, and again WITHOUT quoting the body.
|
|
232
|
-
return yield* Effect.fail(
|
|
233
|
-
new Error(
|
|
234
|
-
`${target.mount}/creds/${role} returned no token_id/secret. Either this approle lacks ` +
|
|
235
|
-
`the grant, or the mount is not ${target.mount}. Say which mount and role you needed ` +
|
|
236
|
-
`than falling back to a stored credential -- every stored PVE token is read-only.`,
|
|
237
|
-
),
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
const lease = parsed?.lease_duration;
|
|
241
|
-
const leaseSeconds = typeof lease === 'number' ? lease : 0;
|
|
242
|
-
return { leaseSeconds, secret, tokenId } satisfies PveCredential;
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* The header PVE wants. `PVEAPIToken=<tokenid>=<secret>`.
|
|
247
|
-
*
|
|
248
|
-
* ⚠️ THE SEPARATOR IS `=`, NOT `:`. Proxmox rejects the colon form with a 401 that says only
|
|
249
|
-
* "authentication failure", which reads as a bad credential rather than as a malformed header.
|
|
250
|
-
*/
|
|
251
|
-
export const authorization = (credential: PveCredential, target: ApiTarget): string =>
|
|
252
|
-
target.scheme === 'pbs'
|
|
253
|
-
? `PBSAPIToken=${credential.tokenId}:${credential.secret}`
|
|
254
|
-
: `PVEAPIToken=${credential.tokenId}=${credential.secret}`;
|
|
121
|
+
// ★ `mint` and `authorization` live in mint.ts (2026-09-24) — this file is types only now, to
|
|
122
|
+
// stay under the 250-line cap once `PveCredentialDenied`'s catchers needed a real header. See
|
|
123
|
+
// mint.ts's own header. Importers: mint.ts, client.ts, lease-cache.ts, lxc-lifecycle.ts,
|
|
124
|
+
// network-apply.ts, credentials.test.ts.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor-constraint guards (constraint-guard.ts) for a distilled-backed family — the seam
|
|
3
|
+
* acl.ts's own local `guardWrite` established (acl-wire.ts), generalized once a second family
|
|
4
|
+
* (Group) needed the SAME create/update split `resource.ts`'s `specGuards` (resource-guard.ts)
|
|
5
|
+
* gives every family still on `client.ts`. `codegen/constraints.ts` scans this package's SOURCE
|
|
6
|
+
* TEXT for the endpoint-key strings a caller passes here, so a family switching to this helper
|
|
7
|
+
* changes nothing about which vendor rules apply — same keys, same generated tables.
|
|
8
|
+
*
|
|
9
|
+
* ★ WHY `asForm` IS THE ONLY TRANSLATION NEEDED. Distilled's generated request types for every
|
|
10
|
+
* PVE form-urlencoded operation are already `Record<string, string | undefined>` — the SAME
|
|
11
|
+
* shape `PveForm`/`guardForm` want, minus the `?`. There is no JSON vs form-encoding gap to
|
|
12
|
+
* bridge here the way client.ts's `pve()` had to.
|
|
13
|
+
*/
|
|
14
|
+
import type * as Effect from 'effect/Effect';
|
|
15
|
+
import { guardForm } from './constraint-guard.ts';
|
|
16
|
+
import type { EndpointKey } from './constraints.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drops non-string fields — distilled's request type carries `?`, `guardForm` wants a plain
|
|
20
|
+
* form. ⚠️ `body: object`, NOT `Record<string, string | undefined>`: distilled's generated
|
|
21
|
+
* request types are `interface`s with no index signature, so TypeScript refuses that stricter
|
|
22
|
+
* parameter type for every one of them ("index signature ... is missing") even though every
|
|
23
|
+
* field they declare fits it. `object` accepts any of them; the cast below is where the shape
|
|
24
|
+
* is actually asserted, once, rather than at every call site.
|
|
25
|
+
*/
|
|
26
|
+
export const asForm = (body: object): Record<string, string> =>
|
|
27
|
+
Object.fromEntries(
|
|
28
|
+
Object.entries(body as Record<string, unknown>).filter(
|
|
29
|
+
(e): e is [string, string] => typeof e[1] === 'string',
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `presence`: whether a CREATE is really about to happen (required fields are enforced) or the
|
|
35
|
+
* form is only being value-checked in passing — see resource-guard.ts's own header on why this
|
|
36
|
+
* is not "this is the create form" but "a create is really about to happen".
|
|
37
|
+
*/
|
|
38
|
+
export const guardWrite = (
|
|
39
|
+
endpoint: EndpointKey,
|
|
40
|
+
body: object,
|
|
41
|
+
presence: boolean,
|
|
42
|
+
): Effect.Effect<void> => guardForm(endpoint, asForm(body), presence);
|
|
@@ -58,6 +58,7 @@ import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
|
58
58
|
import { credentials as distilledPveCredentials } from '@distilled.cloud/proxmox/Credentials';
|
|
59
59
|
import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
|
|
60
60
|
import * as ProxmoxRetry from '@distilled.cloud/proxmox/Retry';
|
|
61
|
+
import type { PveCredentialDenied } from './credential-errors.ts';
|
|
61
62
|
import type { PveCredential, PveRole, PveTarget } from './credentials.ts';
|
|
62
63
|
import { leased } from './lease-cache.ts';
|
|
63
64
|
import {
|
|
@@ -131,16 +132,26 @@ export const runPveWith = <A, E>(
|
|
|
131
132
|
* resource file calls.
|
|
132
133
|
*
|
|
133
134
|
* ⚠️ `| Error` IN THE RETURN TYPE IS `leased()`'s OWN FAILURE, NOT A NEW ONE THIS FILE ADDS.
|
|
134
|
-
* `
|
|
135
|
-
*
|
|
136
|
-
*
|
|
135
|
+
* `mint.ts`'s `mint` fails with a plain `Error` for most refusals (sealed, no such role) and
|
|
136
|
+
* was never typed more narrowly there; `pve()` carried the same untyped possibility before
|
|
137
|
+
* this file existed, just without a signature that said so.
|
|
138
|
+
*
|
|
139
|
+
* ★ `| PveCredentialDenied` IS SPELLED OUT HERE, NOT LEFT TO COLLAPSE INTO `Error`. It IS an
|
|
140
|
+
* `Error` subtype (Data.TaggedError), so leaving it out of this union would still typecheck —
|
|
141
|
+
* but `Effect.catchTag('PveCredentialDenied', …)` downstream (unreadable-read.ts) needs the
|
|
142
|
+
* tag to be STATICALLY visible in the error channel to compile at all, not merely true at
|
|
143
|
+
* runtime. Naming it here is what lets a migrated family's `readOrUnreadable` catch it.
|
|
137
144
|
*/
|
|
138
145
|
export const runPve = <A, E>(
|
|
139
146
|
target: PveTarget,
|
|
140
147
|
role: PveRole,
|
|
141
148
|
isWrite: boolean,
|
|
142
149
|
op: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
143
|
-
): Effect.Effect<
|
|
150
|
+
): Effect.Effect<
|
|
151
|
+
A,
|
|
152
|
+
E | Cause.TimeoutError | PveClusterExhausted | PveCredentialDenied | Error,
|
|
153
|
+
HttpClient.HttpClient
|
|
154
|
+
> =>
|
|
144
155
|
Effect.gen(function* () {
|
|
145
156
|
const credential = yield* leased(target, role);
|
|
146
157
|
return yield* runPveWith(target, credential, isWrite, op);
|
|
@@ -52,4 +52,4 @@ export const PROXMOX_CONSTRAINTS: Readonly<Record<string, EndpointConstraints>>
|
|
|
52
52
|
* ★ A DIGEST OF THE DATA, NOT OF THE FILE TEXT, so reformatting is not a stale generation while
|
|
53
53
|
* changing a 128 to a 129 by hand is. `tests/schema-manifest.test.ts` recomputes it.
|
|
54
54
|
*/
|
|
55
|
-
export const PROXMOX_CONSTRAINTS_DIGEST = '
|
|
55
|
+
export const PROXMOX_CONSTRAINTS_DIGEST = '56eba80750ddb1d2';
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -39,9 +39,6 @@ export const PVE_ACCESS_CONSTRAINTS: Readonly<Record<string, EndpointConstraints
|
|
|
39
39
|
"password": {"maxLength":64,"minLength":8,"type":"string"},
|
|
40
40
|
"userid": {"format":"pve-userid","maxLength":64,"required":true,"type":"string"},
|
|
41
41
|
},
|
|
42
|
-
"pve:POST /access/users/{userid}/token/{tokenid}": {
|
|
43
|
-
"expire": {"default":"same as user","minimum":0,"type":"integer"},
|
|
44
|
-
},
|
|
45
42
|
"pve:PUT /access/acl": {
|
|
46
43
|
"groups": {"format":"pve-groupid-list","type":"string"},
|
|
47
44
|
"path": {"required":true,"type":"string"},
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sha256 9def8f13611184ee, read on a PVE cluster node from
|
|
7
7
|
* /usr/share/pve-docs/api-viewer/apidoc.js
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 58 of this product's 258 POST/PUT endpoints are tabled across all areas: the ones
|
|
10
10
|
* this package writes to, named in its own source. Every other vendor write endpoint is UNTABLED
|
|
11
11
|
* and therefore unchecked at plan time.
|
|
12
12
|
*
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Group`'s wire shape: PVE's create/update forms and how a live item becomes
|
|
3
|
+
* attributes. Split out of group.ts (2026-09-24) to keep that file under the 250-line cap —
|
|
4
|
+
* the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
|
|
5
|
+
*/
|
|
6
|
+
import type * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import type { GroupAttributes, GroupProps } from './group.ts';
|
|
8
|
+
import { csv, text } from './values.ts';
|
|
9
|
+
|
|
10
|
+
export const GROUP_CREATE = 'pve:POST /access/groups';
|
|
11
|
+
export const GROUP_UPDATE = 'pve:PUT /access/groups/{groupid}';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The comment PVE will actually END UP HOLDING for a given declaration.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ PVE CANNOT STORE THE COMMENT `'0'`, AND A PROVIDER THAT DOES NOT MODEL THAT DIFFS FOREVER.
|
|
17
|
+
* `create_group`/`write_user_config`/the config parser all test the comment for TRUTH, and in
|
|
18
|
+
* Perl the one-character string `'0'` is false. So a declared `comment: '0'` is accepted by the
|
|
19
|
+
* API, dropped on the way to `user.cfg`, and read back as absent. Normalising BOTH sides is how
|
|
20
|
+
* `values.ts` already treats `''` as the absence of a boolean rather than as `false`.
|
|
21
|
+
*/
|
|
22
|
+
export const storedComment = (comment: string | undefined) => {
|
|
23
|
+
const value = comment ?? '';
|
|
24
|
+
return value === '0' ? '' : value;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The item read's `members` array, sorted into something two plans can agree on — group.ts's header. */
|
|
28
|
+
const memberList = (live: readonly string[]): string[] => {
|
|
29
|
+
const joined = csv(live.map((member) => text(member)));
|
|
30
|
+
return joined === '' ? [] : joined.split(',');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const createForm = (props: GroupProps): access.CreateAccessGroupRequest => ({
|
|
34
|
+
comment: storedComment(props.comment),
|
|
35
|
+
groupid: props.groupid,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** ⚠️ ALWAYS SENT, EVEN EMPTY — that is how a comment is CLEARED (see group.ts's own header). */
|
|
39
|
+
export const updateForm = (props: GroupProps): access.PutAccessGroupRequest => ({
|
|
40
|
+
comment: storedComment(props.comment),
|
|
41
|
+
groupid: props.groupid,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const attributesOf = (
|
|
45
|
+
live: access.GetAccessGroupResponse,
|
|
46
|
+
props: GroupProps,
|
|
47
|
+
): GroupAttributes => ({
|
|
48
|
+
/**
|
|
49
|
+
* ⚠️ ABSENT IS EMPTY, NOT MISSING — `read_group` sets `comment` only `if defined(…)`. distilled
|
|
50
|
+
* types it `comment?: string`, so `text()`'s `''` fallback meets the same normalisation.
|
|
51
|
+
*/
|
|
52
|
+
comment: text(live.comment),
|
|
53
|
+
/** ⚠️ FROM PROPS — the item read never echoes the id back. */
|
|
54
|
+
groupid: props.groupid,
|
|
55
|
+
members: memberList(live.members),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ `members` IS NOT COMPARED, AND NOTHING ELSE IS LEFT TO COMPARE. `comment` is the only field
|
|
60
|
+
* a PUT here accepts, so it is the only field whose drift this provider can repair.
|
|
61
|
+
*/
|
|
62
|
+
export const matches = (attributes: GroupAttributes, props: GroupProps) =>
|
|
63
|
+
attributes.comment === storedComment(props.comment);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Group` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
|
|
3
|
+
* `@distilled.cloud/proxmox` protocol — the family's own create/drift/retain/destroy paths;
|
|
4
|
+
* adopt-noop.test.ts owns the cross-family "adopting a matching object only reads" proof.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Layer from 'effect/Layer';
|
|
10
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
11
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
12
|
+
import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
13
|
+
|
|
14
|
+
type Live = { comment: string | undefined; members: string[] };
|
|
15
|
+
|
|
16
|
+
/** `access/groups` (POST) and `access/groups/{groupid}` (GET/PUT/DELETE), keyed by groupid. */
|
|
17
|
+
const cluster = (groups: Map<string, Live>) =>
|
|
18
|
+
fakePve((call: PveCall) => {
|
|
19
|
+
const match = /^access\/groups(?:\/(.+))?$/.exec(call.path);
|
|
20
|
+
if (match === null) return undefined;
|
|
21
|
+
const groupid = match[1];
|
|
22
|
+
if (call.method === 'GET' && groupid !== undefined) return groups.get(groupid);
|
|
23
|
+
if (call.method === 'POST' && groupid === undefined) {
|
|
24
|
+
groups.set(call.form['groupid'] ?? '', { comment: call.form['comment'], members: [] });
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (call.method === 'PUT' && groupid !== undefined) {
|
|
28
|
+
const live = groups.get(groupid);
|
|
29
|
+
if (live !== undefined) groups.set(groupid, { ...live, comment: call.form['comment'] });
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (call.method === 'DELETE' && groupid !== undefined) {
|
|
33
|
+
groups.delete(groupid);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const declare = (comment?: string) =>
|
|
40
|
+
ProxmoxGroup('mint', {
|
|
41
|
+
groupid: 'hf-mint',
|
|
42
|
+
target: FAKE_TARGET,
|
|
43
|
+
...(comment === undefined ? {} : { comment }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
47
|
+
engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
48
|
+
|
|
49
|
+
describe('Proxmox.Group over the distilled protocol', () => {
|
|
50
|
+
test('creating a group that does not exist yet: one POST', async () => {
|
|
51
|
+
const groups = new Map<string, Live>();
|
|
52
|
+
const fake = cluster(groups);
|
|
53
|
+
await withoutBao(async () => {
|
|
54
|
+
const engine = engineFor(fake);
|
|
55
|
+
expect(await engine.deploy(declare('fence'))).toEqual({ mint: 'create' });
|
|
56
|
+
});
|
|
57
|
+
expect(fake.writes()).toEqual(['POST access/groups']);
|
|
58
|
+
expect(groups.get('hf-mint')).toEqual({ comment: 'fence', members: [] });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a drifted comment is corrected with exactly one PUT', async () => {
|
|
62
|
+
const groups = new Map<string, Live>([['hf-mint', { comment: 'old', members: [] }]]);
|
|
63
|
+
const fake = cluster(groups);
|
|
64
|
+
await withoutBao(async () => {
|
|
65
|
+
const engine = engineFor(fake);
|
|
66
|
+
const report = await engine.verify(declare('fence'));
|
|
67
|
+
expect(report.rows[0]).toMatchObject({ diff: 'update' });
|
|
68
|
+
expect(await engine.deploy(declare('fence'))).toEqual({ mint: 'adopted' });
|
|
69
|
+
});
|
|
70
|
+
expect(fake.writes()).toEqual(['PUT access/groups/hf-mint']);
|
|
71
|
+
expect(groups.get('hf-mint')?.comment).toBe('fence');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ⛔ PVE cannot store the literal comment '0' (group.ts's header) — a declared '0' must read
|
|
75
|
+
// back as noop against a live '' rather than diffing forever.
|
|
76
|
+
test("a declared comment of '0' matches a live empty comment (PVE's own truthiness trap)", async () => {
|
|
77
|
+
const groups = new Map<string, Live>([['hf-mint', { comment: '', members: [] }]]);
|
|
78
|
+
const fake = cluster(groups);
|
|
79
|
+
await withoutBao(async () => {
|
|
80
|
+
const engine = engineFor(fake);
|
|
81
|
+
const report = await engine.verify(declare('0'));
|
|
82
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
83
|
+
});
|
|
84
|
+
expect(fake.writes()).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('undeclaring sends no DELETE (retain is the default)', async () => {
|
|
88
|
+
const groups = new Map<string, Live>();
|
|
89
|
+
const fake = cluster(groups);
|
|
90
|
+
await withoutBao(async () => {
|
|
91
|
+
const engine = engineFor(fake);
|
|
92
|
+
await engine.deploy(declare('fence'));
|
|
93
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
94
|
+
});
|
|
95
|
+
expect(fake.writes()).toEqual(['POST access/groups']);
|
|
96
|
+
expect(groups.has('hf-mint')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('RemovalPolicy.destroy() sends the DELETE', async () => {
|
|
100
|
+
const groups = new Map<string, Live>();
|
|
101
|
+
const fake = cluster(groups);
|
|
102
|
+
await withoutBao(async () => {
|
|
103
|
+
const engine = engineFor(fake);
|
|
104
|
+
await engine.deploy(declare('fence').pipe(RemovalPolicy.destroy()));
|
|
105
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
106
|
+
});
|
|
107
|
+
expect(fake.writes()).toEqual(['POST access/groups', 'DELETE access/groups/hf-mint']);
|
|
108
|
+
expect(groups.has('hf-mint')).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|