@homeflare/alchemy 0.31.0 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- 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 +68 -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 +730 -511
- 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 +47 -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/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- package/docs/upstream-conformance.md +14 -4
- package/package.json +2 -2
- package/src/grafana/index.ts +6 -5
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +36 -83
- 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 +155 -133
- 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 +117 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +127 -109
- 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/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +152 -104
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +148 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +136 -131
- package/src/verify/fake-engine.ts +23 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cries-wolf fix (unreadable-read.ts), proved end to end for `Proxmox.Acl` — the reference
|
|
3
|
+
* migrated family — through Alchemy's own Plan and Apply, over a fake cluster whose OpenBao mint
|
|
4
|
+
* can be flipped to DENY the `provision` role mid-test, the way the agent lane's policy denies it
|
|
5
|
+
* for real (measured 2026-09-24, decision doc's "cries wolf" entry: 14 ACLs read back as absent
|
|
6
|
+
* and forced `update` with nothing compared).
|
|
7
|
+
*
|
|
8
|
+
* ★ ONE ENGINE, ONE TRANSPORT, A MUTABLE DENIAL FLAG — not two fakes — because the state row this
|
|
9
|
+
* test needs (`output !== undefined`, the exact case the bug forced `update` for) has to
|
|
10
|
+
* survive from the first deploy (mint allowed) into the second plan (mint denied);
|
|
11
|
+
* fake-engine.ts builds its in-memory state store fresh per `engineOver` call, so the same
|
|
12
|
+
* engine instance is reused for both, and only the fake OpenBao's answer to `/creds/provision`
|
|
13
|
+
* changes in between. `lease_duration: 0` on every fake mint (both branches) keeps
|
|
14
|
+
* lease-cache.ts cold, so the second phase genuinely re-mints and hits the denial.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import * as Layer from 'effect/Layer';
|
|
18
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
19
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
20
|
+
import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
21
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
22
|
+
|
|
23
|
+
type Row = { path: string; type: string; ugid: string; roleid: string; propagate?: number };
|
|
24
|
+
|
|
25
|
+
/** One ACL row, plus an OpenBao mint the test can deny for `provision` mid-run. */
|
|
26
|
+
const clusterDenyingProvision = (rows: readonly Row[]) => {
|
|
27
|
+
let denyProvision = false;
|
|
28
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
29
|
+
const request =
|
|
30
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
if (url.pathname.includes('/creds/')) {
|
|
33
|
+
if (denyProvision && url.pathname.endsWith('/creds/provision')) {
|
|
34
|
+
return Response.json({ errors: ['permission denied'] }, { status: 403 });
|
|
35
|
+
}
|
|
36
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
37
|
+
return Response.json({ data, lease_duration: 0 });
|
|
38
|
+
}
|
|
39
|
+
const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
|
|
40
|
+
if (request.method === 'GET' && path === 'access/acl') return Response.json({ data: rows });
|
|
41
|
+
// ⛔ Any other write is a bug this test would otherwise hide: once denied, `reconcile` must
|
|
42
|
+
// never be reached (diff answers noop), so nothing should ever PUT here again.
|
|
43
|
+
return Response.json({ data: null });
|
|
44
|
+
};
|
|
45
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
46
|
+
return {
|
|
47
|
+
deny: () => {
|
|
48
|
+
denyProvision = true;
|
|
49
|
+
},
|
|
50
|
+
layer: FetchHttpClient.layer.pipe(
|
|
51
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const grant = () =>
|
|
57
|
+
ProxmoxAcl('auditor', {
|
|
58
|
+
path: '/',
|
|
59
|
+
roleid: 'PVEAuditor',
|
|
60
|
+
target: FAKE_TARGET,
|
|
61
|
+
type: 'user',
|
|
62
|
+
ugid: 'u@pve',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('the cries-wolf fix: a refused provision mint reports noop, never update', () => {
|
|
66
|
+
test('an already-correct grant plans noop once the credential is denied, and nothing is written', async () => {
|
|
67
|
+
const rows: Row[] = [
|
|
68
|
+
{ path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
|
|
69
|
+
];
|
|
70
|
+
const fake = clusterDenyingProvision(rows);
|
|
71
|
+
await withoutBao(async () => {
|
|
72
|
+
const engine = engineOver(ProxmoxAclProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
73
|
+
// First deploy: mint allowed, adopts the matching grant and records a state row.
|
|
74
|
+
expect(await engine.deploy(grant())).toEqual({ auditor: 'adopted' });
|
|
75
|
+
// Now the credential's role is denied -- exactly the measured agent-lane scenario.
|
|
76
|
+
fake.deny();
|
|
77
|
+
const report = await engine.verify(grant(), { all: true });
|
|
78
|
+
// ⛔ THE BUG THIS PROVES FIXED: before the fix this row read 'update' with nothing compared.
|
|
79
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
80
|
+
expect(await engine.deploy(grant())).toEqual({ auditor: 'noop' });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "unreadable" outcome a refused read produces — distinct from `undefined` (genuinely
|
|
3
|
+
* absent) and from a real attributes object (present) — so `diff` stops treating a REFUSED read
|
|
4
|
+
* the same as a confirmed-gone one. Fixes the "cries wolf" bug (measured 2026-09-24): on a lane
|
|
5
|
+
* whose credential cannot mint `provision`, every already-correct ACL and storage row read back
|
|
6
|
+
* as absent, and `diff` forced `update` for every one of them without comparing a single field,
|
|
7
|
+
* because `.pipe(Effect.orElseSucceed(() => undefined))` folds every failure — a genuine 404, a
|
|
8
|
+
* network blip, AND a permission refusal — into the one "absent" value `diff` cannot tell apart.
|
|
9
|
+
*
|
|
10
|
+
* ★ WHY A SENTINEL, NOT A FAILED `diff`. Upstream's `Diff` type is exactly
|
|
11
|
+
* `NoopDiff | UpdateDiff | ReplaceDiff` (`packages/alchemy/src/Diff.ts@v2.0.0-beta.79`) — there
|
|
12
|
+
* is no fourth "unreadable" action to return. And `Plan.ts`'s resource pass runs every
|
|
13
|
+
* resource's `diff` under `Effect.exit`, then FAILS THE WHOLE PLAN if even one failed
|
|
14
|
+
* (`diffFailures.length > 0` -> `Effect.failCause`, `Plan.ts@v2.0.0-beta.79` ~lines 1711-1731).
|
|
15
|
+
* So making `diff` FAIL on a refused read would not report one honest row — it would abort
|
|
16
|
+
* `bun run plan` for every OTHER resource in the stack too, worse than today's false "update".
|
|
17
|
+
* `{ action: 'noop' }` plus a logged warning is the only outcome that (a) never forces a write
|
|
18
|
+
* on a lane that cannot prove one is needed and (b) never takes the rest of the plan down with
|
|
19
|
+
* it — the "noop-with-warning" reading of the fix, over the alternative of a typed plan error.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Every other read failure (a genuine
|
|
22
|
+
* `Cause.TimeoutError`, `PveClusterExhausted`, a network error) keeps folding into `undefined`
|
|
23
|
+
* exactly as it always has — this narrows ONE typed tag out of that fold, via `catchTag`, and
|
|
24
|
+
* changes nothing else about it. See credential-errors.ts's own ⛔ for why 403 alone qualifies.
|
|
25
|
+
*/
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import type { PveCredentialDenied } from './credential-errors.ts';
|
|
28
|
+
|
|
29
|
+
/** Distinct from `undefined` (absent) and from a real attributes object (present). */
|
|
30
|
+
export const UNREADABLE = Symbol('pve-unreadable');
|
|
31
|
+
export type Unreadable = typeof UNREADABLE;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Catches ONLY `PveCredentialDenied` and turns it into the sentinel, so a caller that also
|
|
35
|
+
* `.pipe(Effect.orElseSucceed(() => undefined))` for every OTHER failure never conflates a
|
|
36
|
+
* refusal with a genuine absence. Compose this INSIDE that fold, never after it.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ `E | PveCredentialDenied` IN, `E` OUT — not a generic `E` narrowed by `Exclude`. `catchTag`
|
|
39
|
+
* needs the tag to be STATICALLY present in the error union to typecheck at all; a bare
|
|
40
|
+
* unconstrained `E` gives it nothing to match against. Every caller's `runPve` already carries
|
|
41
|
+
* `PveCredentialDenied` explicitly (distilled-pve.ts's own ★ on why), so this signature costs
|
|
42
|
+
* nothing at the call site.
|
|
43
|
+
*/
|
|
44
|
+
export const readOrUnreadable = <A, E, R>(
|
|
45
|
+
effect: Effect.Effect<A, E | PveCredentialDenied, R>,
|
|
46
|
+
): Effect.Effect<A | Unreadable, E, R> =>
|
|
47
|
+
effect.pipe(Effect.catchTag('PveCredentialDenied', () => Effect.succeed(UNREADABLE)));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The warning `diff` logs when it reports `noop` for a row it could not actually read — visible
|
|
51
|
+
* in `bun run plan`'s own output (Effect's default logger), even though the Diff type has
|
|
52
|
+
* nowhere to carry the distinction through to the printed action. See the header for why a log
|
|
53
|
+
* line, not a failed diff or a fourth Diff action, is what "the plan reports" means here.
|
|
54
|
+
*/
|
|
55
|
+
export const unreadableWarning = (type: string, identity: string): Effect.Effect<void> =>
|
|
56
|
+
Effect.logWarning(
|
|
57
|
+
`${type} ${identity}: the read was refused (no grant for this credential's role) -- ` +
|
|
58
|
+
'reporting noop rather than forcing an update with nothing compared. Re-plan on a lane ' +
|
|
59
|
+
'that can read this object before trusting this row.',
|
|
60
|
+
);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.User`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
|
|
3
|
+
* and the read itself. Split out of user.ts (2026-09-24, the distilled migration) to keep that
|
|
4
|
+
* file under the 250-line cap — the api-token.ts/api-token-form.ts seam.
|
|
5
|
+
*/
|
|
6
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { runPve } from './distilled-pve.ts';
|
|
9
|
+
import type { UserAttributes, UserProps } from './user.ts';
|
|
10
|
+
import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
|
|
11
|
+
import { bool, text } from './values.ts';
|
|
12
|
+
|
|
13
|
+
export const USER_CREATE = 'pve:POST /access/users';
|
|
14
|
+
export const USER_UPDATE = 'pve:PUT /access/users/{userid}';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ⚠️ MORE FORGIVING THAN `lxc.ts`'s `num`, ON PURPOSE, AND DELIBERATELY NOT `values.ts`'s
|
|
18
|
+
* `int` (a different function of the same name, `Number.parseInt` on a STRING with a
|
|
19
|
+
* caller-given fallback). PVE's JSON is generated from a Perl schema and an integer field can
|
|
20
|
+
* arrive as `0` or `"0"` depending on the release and the endpoint; `Number(value ?? 0)`
|
|
21
|
+
* accepts either without a strict `typeof === 'number'` that would silently fall back for the
|
|
22
|
+
* string form and report an update `matches` can never settle.
|
|
23
|
+
*/
|
|
24
|
+
const int = (value: unknown) => {
|
|
25
|
+
const parsed = typeof value === 'number' ? value : Number(value ?? 0);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An array (what the read returns), a comma string (what older releases and the write side use), or
|
|
31
|
+
* nothing — in; a sorted, deduplicated list out. Both sides of `matches` go through this, which is
|
|
32
|
+
* the only reason the diff ever settles.
|
|
33
|
+
*/
|
|
34
|
+
const groupSet = (value: unknown): string[] => {
|
|
35
|
+
const raw: unknown[] = Array.isArray(value)
|
|
36
|
+
? value
|
|
37
|
+
: typeof value === 'string'
|
|
38
|
+
? value.split(',')
|
|
39
|
+
: [];
|
|
40
|
+
const names = raw.map((group) => text(group).trim()).filter((group) => group !== '');
|
|
41
|
+
return [...new Set(names)].sort();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ⚠️ THE SHAPE DEPENDS ON WHICH ENDPOINT YOU ASKED. Reading ONE user gives an object keyed by
|
|
46
|
+
* token name; distilled types it `tokens?: unknown` for the same reason (the item and index
|
|
47
|
+
* responses disagree, and this provider only ever reads the item). Anything but that shape is
|
|
48
|
+
* reported as "no tokens" rather than guessed at.
|
|
49
|
+
*/
|
|
50
|
+
const tokenNames = (value: unknown): string[] =>
|
|
51
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
52
|
+
? Object.keys(value).sort()
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
/** Everything mutable, in the form PVE wants. Create adds `userid`; update sends exactly this. */
|
|
56
|
+
const shape = (props: UserProps) => ({
|
|
57
|
+
comment: props.comment ?? '',
|
|
58
|
+
email: props.email ?? '',
|
|
59
|
+
enable: props.enable === false ? '0' : '1',
|
|
60
|
+
expire: String(props.expire ?? 0),
|
|
61
|
+
firstname: props.firstname ?? '',
|
|
62
|
+
/**
|
|
63
|
+
* ⚠️ ALWAYS SENT, EVEN EMPTY. Omitting `groups` on a PUT leaves the existing memberships in
|
|
64
|
+
* place, so a group removed from the declaration would never actually be removed. Same for
|
|
65
|
+
* `email` and `comment`: the empty string is how PVE is told to clear a field.
|
|
66
|
+
* ⚠️ AND `append` IS DELIBERATELY NOT SET. Its default (0) REPLACES the list; setting it to 1
|
|
67
|
+
* would turn every reconcile into an add-only merge, which is the same non-settling diff.
|
|
68
|
+
*/
|
|
69
|
+
groups: groupSet(props.groups).join(','),
|
|
70
|
+
lastname: props.lastname ?? '',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const createForm = (props: UserProps): access.CreateAccessUserRequest => ({
|
|
74
|
+
...shape(props),
|
|
75
|
+
userid: props.userid,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const updateForm = (props: UserProps): access.PutAccessUserRequest => ({
|
|
79
|
+
...shape(props),
|
|
80
|
+
userid: props.userid,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const attributesOf = (live: access.GetAccessUserResponse, props: UserProps): UserAttributes => ({
|
|
84
|
+
comment: text(live.comment),
|
|
85
|
+
email: text(live.email),
|
|
86
|
+
/**
|
|
87
|
+
* ⚠️ ABSENT MEANS ENABLED. PVE's schema defaults `enable` to 1 and does not always write the
|
|
88
|
+
* key back for an enabled account. Reading absence as `false` would report an update on
|
|
89
|
+
* every plan and then DISABLE the account on the deploy that "fixed" it.
|
|
90
|
+
*/
|
|
91
|
+
enable: bool(live.enable, true),
|
|
92
|
+
expire: int(live.expire),
|
|
93
|
+
firstname: text(live.firstname),
|
|
94
|
+
groups: groupSet(live.groups),
|
|
95
|
+
lastname: text(live.lastname),
|
|
96
|
+
tokens: tokenNames(live.tokens),
|
|
97
|
+
/** ⚠️ FROM PROPS: `GET /access/users/{userid}` does not echo the userid back in its data. */
|
|
98
|
+
userid: props.userid,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
|
|
103
|
+
* what a PUT can put back; a field PVE reports but will not accept is drift nobody can fix.
|
|
104
|
+
*/
|
|
105
|
+
export const matches = (attributes: UserAttributes, props: UserProps) =>
|
|
106
|
+
attributes.comment === (props.comment ?? '') &&
|
|
107
|
+
attributes.email === (props.email ?? '') &&
|
|
108
|
+
attributes.enable === (props.enable !== false) &&
|
|
109
|
+
attributes.expire === (props.expire ?? 0) &&
|
|
110
|
+
attributes.firstname === (props.firstname ?? '') &&
|
|
111
|
+
attributes.lastname === (props.lastname ?? '') &&
|
|
112
|
+
attributes.groups.join(',') === groupSet(props.groups).join(',');
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* ★ Default `read` role for this family — only the write side needs `provision`.
|
|
116
|
+
* ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
|
|
117
|
+
* kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
|
|
118
|
+
* key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
|
|
119
|
+
* family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
|
|
120
|
+
* JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
|
|
121
|
+
* fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
|
|
122
|
+
* (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
|
|
123
|
+
* which `runPveWith` retries across every member and exhausts identically —
|
|
124
|
+
* `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
|
|
125
|
+
* false `update` on every account.
|
|
126
|
+
*/
|
|
127
|
+
export const readUserOrFail = (props: UserProps) =>
|
|
128
|
+
readOrUnreadable(
|
|
129
|
+
runPve(props.target, 'read', false, access.getAccessUser({ userid: props.userid })),
|
|
130
|
+
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
|
|
134
|
+
* missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
|
|
135
|
+
* "absent" from a failure to ever run — without this fold, a brand-new account could never be
|
|
136
|
+
* created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
|
|
137
|
+
* which PVE refuses loudly rather than silently corrupting anything.
|
|
138
|
+
* ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
|
|
139
|
+
* folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
|
|
140
|
+
* nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
|
|
141
|
+
* propagates and fails the whole plan loudly instead.
|
|
142
|
+
*/
|
|
143
|
+
export const readUser = (props: UserProps) =>
|
|
144
|
+
readUserOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
|
|
145
|
+
|
|
146
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
147
|
+
export const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
|
|
148
|
+
live === UNREADABLE ? undefined : live;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.User` through Alchemy's own Plan and Apply, over a fake cluster driving the REAL
|
|
3
|
+
* `@distilled.cloud/proxmox` protocol — create, drift (including the `groups` array/comma-string
|
|
4
|
+
* asymmetry the header warns about), and delete. adopt-noop.test.ts (via Group) proves the
|
|
5
|
+
* shared "adopting a matching object only reads" property; this file is User's own write paths.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ A MISSING USER IS A REAL 500, NOT `fakePve`'s USUAL 200-with-null. user.ts's own header
|
|
8
|
+
* measures this, and it matters here: `GetAccessUserResponse` has NO required field, so a
|
|
9
|
+
* 200-with-`{}` (what `fakePve` always answers) decodes as a fully-present, all-default
|
|
10
|
+
* account rather than "absent" — the read would never fail and `create` would never trigger.
|
|
11
|
+
* `missingAs500` below is a small custom stub, not `fake-pve.ts`'s `fakePve`, for that reason.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Layer from 'effect/Layer';
|
|
16
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
17
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
18
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { ProxmoxUser, ProxmoxUserProvider, type UserProps } from './user.ts';
|
|
20
|
+
|
|
21
|
+
type Live = { comment: string; enable: string; expire: string; groups: readonly string[] };
|
|
22
|
+
|
|
23
|
+
/** `access/users` (POST) and `access/users/{userid}` (GET/PUT), a missing GET answering 500. */
|
|
24
|
+
const missingAs500 = (users: Map<string, Live>) => {
|
|
25
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
26
|
+
const request =
|
|
27
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
if (url.pathname.includes('/creds/')) {
|
|
30
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
31
|
+
return Response.json({ data, lease_duration: 0 });
|
|
32
|
+
}
|
|
33
|
+
const path = url.pathname.replace(/^\/api2\/json\//, '');
|
|
34
|
+
const match = /^access\/users(?:\/(.+))?$/.exec(path);
|
|
35
|
+
if (match === null) return Response.json({ data: null });
|
|
36
|
+
// ⚠️ distilled percent-encodes a `{userid}` label substitution (`iac@pve` -> `iac%40pve`,
|
|
37
|
+
// MEASURED) where client.ts's plain string concatenation never did. A real PVE decodes it
|
|
38
|
+
// like any HTTP server; this fake must too, or every userid with an `@` "vanishes".
|
|
39
|
+
const userid = match[1] === undefined ? undefined : decodeURIComponent(match[1]);
|
|
40
|
+
const params = new URLSearchParams(await request.text());
|
|
41
|
+
const form = Object.fromEntries(params);
|
|
42
|
+
if (request.method === 'GET' && userid !== undefined) {
|
|
43
|
+
const live = users.get(userid);
|
|
44
|
+
// ⚠️ THE TRAP THIS FILE EXISTS TO PROVE FIXED: measured PVE answers 500, not 200-null.
|
|
45
|
+
if (live === undefined)
|
|
46
|
+
return Response.json({ message: `no such user ('${userid}')` }, { status: 500 });
|
|
47
|
+
return Response.json({ data: { ...live, groups: [...live.groups] } });
|
|
48
|
+
}
|
|
49
|
+
if (request.method === 'POST' && userid === undefined) {
|
|
50
|
+
const { userid: id, ...rest } = form;
|
|
51
|
+
if (id !== undefined) {
|
|
52
|
+
users.set(id, {
|
|
53
|
+
comment: rest['comment'] ?? '',
|
|
54
|
+
enable: rest['enable'] ?? '1',
|
|
55
|
+
expire: rest['expire'] ?? '0',
|
|
56
|
+
groups:
|
|
57
|
+
rest['groups'] === undefined || rest['groups'] === '' ? [] : rest['groups'].split(','),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return Response.json({ data: null });
|
|
61
|
+
}
|
|
62
|
+
if (request.method === 'PUT' && userid !== undefined) {
|
|
63
|
+
const live = users.get(userid);
|
|
64
|
+
if (live !== undefined) {
|
|
65
|
+
users.set(userid, {
|
|
66
|
+
comment: form['comment'] ?? live.comment,
|
|
67
|
+
enable: form['enable'] ?? live.enable,
|
|
68
|
+
expire: form['expire'] ?? live.expire,
|
|
69
|
+
groups:
|
|
70
|
+
form['groups'] === undefined || form['groups'] === '' ? [] : form['groups'].split(','),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return Response.json({ data: null });
|
|
74
|
+
}
|
|
75
|
+
if (request.method === 'DELETE' && userid !== undefined) {
|
|
76
|
+
users.delete(userid);
|
|
77
|
+
return Response.json({ data: null });
|
|
78
|
+
}
|
|
79
|
+
return Response.json({ data: null });
|
|
80
|
+
};
|
|
81
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
82
|
+
return {
|
|
83
|
+
layer: FetchHttpClient.layer.pipe(
|
|
84
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const declare = (over: Partial<UserProps> = {}): UserProps => ({
|
|
90
|
+
target: FAKE_TARGET,
|
|
91
|
+
userid: 'iac@pve',
|
|
92
|
+
...over,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Proxmox.User over the distilled protocol', () => {
|
|
96
|
+
test('creating an account that does not exist yet: one POST, defaults sent explicitly', async () => {
|
|
97
|
+
const users = new Map<string, Live>();
|
|
98
|
+
const fake = missingAs500(users);
|
|
99
|
+
await withoutBao(async () => {
|
|
100
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
101
|
+
expect(await engine.deploy(ProxmoxUser('iac', declare()))).toEqual({ iac: 'create' });
|
|
102
|
+
});
|
|
103
|
+
expect(users.get('iac@pve')).toEqual({ comment: '', enable: '1', expire: '0', groups: [] });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a drifted comment settles, and an unsorted live groups array is not itself drift', async () => {
|
|
107
|
+
const users = new Map<string, Live>([
|
|
108
|
+
['iac@pve', { comment: 'old', enable: '1', expire: '0', groups: ['b', 'a'] }],
|
|
109
|
+
]);
|
|
110
|
+
const fake = missingAs500(users);
|
|
111
|
+
await withoutBao(async () => {
|
|
112
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
113
|
+
const declared = () => ProxmoxUser('iac', declare({ comment: 'new', groups: ['a', 'b'] }));
|
|
114
|
+
const report = await engine.verify(declared());
|
|
115
|
+
expect(report.rows[0]).toMatchObject({ changed: ['comment'], diff: 'update' });
|
|
116
|
+
expect(await engine.deploy(declared())).toEqual({ iac: 'adopted' });
|
|
117
|
+
});
|
|
118
|
+
expect(users.get('iac@pve')).toMatchObject({ comment: 'new', groups: ['a', 'b'] });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('deleting removes the account (no retain default for User)', async () => {
|
|
122
|
+
const users = new Map<string, Live>([
|
|
123
|
+
['iac@pve', { comment: '', enable: '1', expire: '0', groups: [] }],
|
|
124
|
+
]);
|
|
125
|
+
const fake = fakePve((call: PveCall) => {
|
|
126
|
+
const match = /^access\/users\/(.+)$/.exec(call.path);
|
|
127
|
+
if (match === null) return undefined;
|
|
128
|
+
// ⚠️ decodeURIComponent — see the ⚠️ on `missingAs500` above: distilled percent-encodes it.
|
|
129
|
+
const userid = decodeURIComponent(match[1] as string);
|
|
130
|
+
if (call.method === 'GET') return users.get(userid);
|
|
131
|
+
if (call.method === 'DELETE') users.delete(userid);
|
|
132
|
+
return undefined;
|
|
133
|
+
});
|
|
134
|
+
await withoutBao(async () => {
|
|
135
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
136
|
+
await engine.deploy(ProxmoxUser('iac', declare()));
|
|
137
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
138
|
+
});
|
|
139
|
+
expect(users.has('iac@pve')).toBe(false);
|
|
140
|
+
expect(fake.writes()).toEqual(['DELETE access/users/iac%40pve']);
|
|
141
|
+
});
|
|
142
|
+
});
|