@homeflare/alchemy 0.27.4 → 0.29.0
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 +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/github/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +9 -5
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +161 -25
- package/dist/github/index.js.map +15 -13
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +37 -3
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +15 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
- package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +91 -0
- package/dist/github/repository-ruleset-props.d.ts.map +1 -0
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
- package/dist/github/repository-ruleset.d.ts +28 -34
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +9 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline.ts +6 -6
- package/src/github/index.ts +10 -4
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +3 -2
- package/src/github/repository-ruleset-errors.ts +51 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +11 -4
- package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
- package/src/github/repository-ruleset-narrowing.test.ts +210 -0
- package/src/github/repository-ruleset-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +95 -0
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-reconcile.ts +35 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
- package/src/github/repository-ruleset.ts +53 -43
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
package/src/proxmox/acl.ts
CHANGED
|
@@ -6,35 +6,50 @@
|
|
|
6
6
|
* {path, user|group|token, role} exists. That is what makes those four families worth declaring,
|
|
7
7
|
* and why the grants that let OpenBao mint a PVE token today are only clicks somebody made once.
|
|
8
8
|
*
|
|
9
|
-
* ⛔ THE ONE FAMILY THAT DOES NOT FIT
|
|
9
|
+
* ⛔ THE ONE FAMILY THAT DOES NOT FIT A GENERIC PATH+FORM SHAPE, AND THE MISFIT IS MEASURED. The
|
|
10
10
|
* cluster's own schema — `/pve-docs/api-viewer/apidoc.js`, read unauthenticated 2026-09-13 —
|
|
11
11
|
* lists exactly TWO methods on `/access/acl`: GET and PUT. There is no DELETE (it answers
|
|
12
|
-
* "Method 'DELETE /access/acl' not implemented") and, less famously, NO POST either
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* with it, and this override disappears. Until then it is local and loud — do not "restore
|
|
17
|
-
* symmetry" by pointing `delete` at `ops.destroy`, which reports every removal as a 501 while
|
|
18
|
-
* the grant stays exactly where it was.
|
|
12
|
+
* "Method 'DELETE /access/acl' not implemented") and, less famously, NO POST either — which is
|
|
13
|
+
* why `@distilled.cloud/proxmox`'s generator, reading the same schema, emits `listAccessAcl` and
|
|
14
|
+
* `putAccessAcl` and NOTHING ELSE for this path. So create and update are the SAME call, `PUT
|
|
15
|
+
* /access/acl`, and removal is that PUT with `delete=1` — there is no `destroyAccessAcl` to call.
|
|
19
16
|
*
|
|
20
17
|
* ⛔ A SINGLE GRANT HAS NO URL OF ITS OWN. `GET /access/acl` answers ONE FLAT LIST for the whole
|
|
21
|
-
* cluster, so identity is the tuple (path, type, ugid, roleid), matched client-side in
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* cluster, so identity is the tuple (path, type, ugid, roleid), matched client-side in `find`
|
|
19
|
+
* below. Two resources declaring the SAME tuple are the SAME grant — Alchemy sees two resource
|
|
20
|
+
* ids, not one collision — and deleting either takes the access away from both, the same hazard
|
|
21
|
+
* as two guests declaring one vmid.
|
|
25
22
|
*
|
|
26
23
|
* ⛔ AND THAT LIST IS FILTERED BY WHO IS ASKING. The schema's own words for GET: "The returned list
|
|
27
24
|
* is restricted to objects where you have rights to modify permissions." A credential that can
|
|
28
25
|
* read the cluster but not modify permissions is answered `[]` — not a 403, not an error — so
|
|
29
26
|
* the mount's `read` role needs permission-modify rights on the declared path exactly as
|
|
30
|
-
* `provision` does
|
|
27
|
+
* `provision` does (below, `listAcl` reads with the `provision` role for this reason). Work
|
|
28
|
+
* reported on a grant that is plainly there means that role is too narrow.
|
|
29
|
+
*
|
|
30
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
|
|
31
|
+
* `access.listAccessAcl`/`access.putAccessAcl` (2026-09-23, decision 43's proxmox walk-down,
|
|
32
|
+
* the sub-area's first resource — ACL was already the misfit `pveOperations` could not cover
|
|
33
|
+
* cleanly, which is exactly why it goes first). `distilled-pve.ts`'s `runPve` replaces `pve()`:
|
|
34
|
+
* same lease reuse (lease-cache.ts), same cluster-member failover (members.ts), now handing the
|
|
35
|
+
* request itself to the SDK instead of a hand-assembled `HttpClientRequest`.
|
|
36
|
+
* ⛔ ONE DEAD BRANCH REMOVED, NOT PRESERVED: the old code's generic factory POSTed on a "live
|
|
37
|
+
* undefined" read, which its own header called unreachable on a healthy cluster and
|
|
38
|
+
* documented as MISLEADING — PVE has no POST here, so that 501 said nothing about the real
|
|
39
|
+
* failure. `@distilled.cloud/proxmox` has no `createAccessAcl` at all (the vendor schema has
|
|
40
|
+
* no POST to generate one from), so there is nothing to call that way. `reconcile` below
|
|
41
|
+
* always PUTs instead — the only write PVE actually implements for this path — so a read that
|
|
42
|
+
* genuinely failed now surfaces its REAL cause (a typed error, or `PveClusterExhausted`)
|
|
43
|
+
* rather than a confusing 501 from a verb that was never there.
|
|
31
44
|
*/
|
|
32
45
|
import { Resource } from 'alchemy';
|
|
33
46
|
import { isResolved } from 'alchemy/Diff';
|
|
34
47
|
import * as Provider from 'alchemy/Provider';
|
|
48
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
35
49
|
import * as Effect from 'effect/Effect';
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
50
|
+
import { guardForm } from './constraint-guard.ts';
|
|
51
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
52
|
+
import { runPve } from './distilled-pve.ts';
|
|
38
53
|
|
|
39
54
|
/** PVE's three kinds of subject. The read answers this word; the write wants its plural. */
|
|
40
55
|
export type AclSubjectType = 'user' | 'group' | 'token';
|
|
@@ -42,8 +57,8 @@ export type AclSubjectType = 'user' | 'group' | 'token';
|
|
|
42
57
|
export interface AclProps extends WithTarget {
|
|
43
58
|
/**
|
|
44
59
|
* The PVE object path the grant is ON — `/`, `/pool/lab`, `/vms/101`, `/storage/local-zfs`.
|
|
45
|
-
* ⚠️ NOT THE API PATH
|
|
46
|
-
*
|
|
60
|
+
* ⚠️ NOT THE API PATH — the endpoint is fixed (`access/acl`). PVE's API overloads the word,
|
|
61
|
+
* so this file does too rather than renaming a field the cluster calls `path`.
|
|
47
62
|
*/
|
|
48
63
|
path: string;
|
|
49
64
|
/** Which kind of subject `ugid` names. Identity: changing it is a different grant. */
|
|
@@ -62,13 +77,7 @@ export interface AclAttributes {
|
|
|
62
77
|
ugid: string;
|
|
63
78
|
roleid: string;
|
|
64
79
|
propagate: boolean;
|
|
65
|
-
/**
|
|
66
|
-
* ⛔ PRESENCE AS AN ATTRIBUTE, AND IT IS WHAT MAKES THE FACTORY WORK HERE. `attributes` never
|
|
67
|
-
* returns undefined: if it did, `reconcile` would take the create branch and POST to
|
|
68
|
-
* `access/acl`, which PVE does not implement. Present-but-unbound sends every write down the
|
|
69
|
-
* PUT branch instead, the only branch PVE has. The cost: the factory's read-back guard cannot
|
|
70
|
-
* fire, so `reconcile` below re-checks this field itself and dies with the same honesty.
|
|
71
|
-
*/
|
|
80
|
+
/** Presence, so a PUT-only family can tell "matches" from "does not exist yet" — see below. */
|
|
72
81
|
bound: boolean;
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -82,163 +91,134 @@ export interface ProxmoxAcl extends Resource<
|
|
|
82
91
|
|
|
83
92
|
export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
|
|
84
93
|
|
|
85
|
-
/**
|
|
86
|
-
* ⚠️ PVE NORMALISES ACL PATHS AND RETURNS THE NORMALISED FORM, a forever-update trap when only one
|
|
87
|
-
* side of the comparison is normalised. `PVE::AccessControl::normalize_path` collapses repeated
|
|
88
|
-
* slashes and strips the trailing one, so a declared `/pool/lab/` reads back as `/pool/lab`
|
|
89
|
-
* and a naive match never fires again. It also refuses a path with no leading slash, so one is
|
|
90
|
-
* added here rather than letting `pool/lab` 400.
|
|
91
|
-
*/
|
|
94
|
+
/** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
|
|
92
95
|
const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
|
|
93
96
|
|
|
94
|
-
/**
|
|
95
|
-
* ⚠️ THE WRITE NAMES THE SUBJECT WITH A PLURAL KEY THAT DIFFERS PER KIND — `users`, `groups`,
|
|
96
|
-
* `tokens` — while the READ answers a singular `type`/`ugid` pair. The PUT declares
|
|
97
|
-
* `additionalProperties: 0`, so `user=` fails with a 400 rather than being ignored. The bad case
|
|
98
|
-
* is the WRONG plural: a valid parameter naming a subject kind you did not mean.
|
|
99
|
-
*/
|
|
97
|
+
/** The write names the subject with a plural key that differs per kind; the read answers singular. */
|
|
100
98
|
const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
|
|
101
99
|
|
|
102
|
-
/** The
|
|
103
|
-
const
|
|
100
|
+
/** The vendor endpoint every write and every constraint check runs against. */
|
|
101
|
+
const ENDPOINT = 'pve:PUT /access/acl';
|
|
102
|
+
|
|
103
|
+
/** The tuple PVE keys a grant by, as PUT's fields. */
|
|
104
|
+
const tuple = (props: AclProps): access.PutAccessAclRequest => ({
|
|
104
105
|
path: normalize(props.path),
|
|
105
106
|
roles: props.roleid,
|
|
106
107
|
[SUBJECT_FIELD[props.type]]: props.ugid,
|
|
107
108
|
});
|
|
108
109
|
|
|
109
110
|
/** Create and update are one call: the tuple plus the single mutable field. */
|
|
110
|
-
const bind = (props: AclProps):
|
|
111
|
-
propagate: props.propagate === false ? '0' : '1',
|
|
111
|
+
const bind = (props: AclProps): access.PutAccessAclRequest => ({
|
|
112
112
|
...tuple(props),
|
|
113
|
+
propagate: props.propagate === false ? '0' : '1',
|
|
113
114
|
});
|
|
114
115
|
|
|
115
|
-
/**
|
|
116
|
-
const
|
|
117
|
-
|
|
116
|
+
/** `guardForm` wants a plain string-valued form; distilled's request type carries `?` instead. */
|
|
117
|
+
const asForm = (body: access.PutAccessAclRequest): Record<string, string> =>
|
|
118
|
+
Object.fromEntries(Object.entries(body).filter((e): e is [string, string] => e[1] !== undefined));
|
|
118
119
|
|
|
119
120
|
/**
|
|
120
|
-
* ⚠️
|
|
121
|
-
*
|
|
122
|
-
*
|
|
121
|
+
* ⚠️ ALWAYS PRESENCE-CHECKED. `bind`'s output IS the create form (there is no separate,
|
|
122
|
+
* narrower update form for this family — see the header), so the vendor's required-parameter
|
|
123
|
+
* check is never wrong to run; the two separate create/update passes the generic factory ran
|
|
124
|
+
* elsewhere collapse to one call here without losing coverage.
|
|
123
125
|
*/
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
126
|
+
const guardWrite = (props: AclProps) => guardForm(ENDPOINT, asForm(bind(props)), true);
|
|
127
|
+
|
|
128
|
+
/** What a change of this string means: not an edit, a different grant. */
|
|
129
|
+
const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
|
|
130
|
+
[normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
|
|
131
|
+
|
|
132
|
+
const find = (rows: readonly access.ListAccessAclResponseBodyItem[], props: AclProps) =>
|
|
133
|
+
rows.find(
|
|
134
|
+
(row) =>
|
|
135
|
+
row.path === normalize(props.path) &&
|
|
136
|
+
row.type === props.type &&
|
|
137
|
+
row.ugid === props.ugid &&
|
|
138
|
+
row.roleid === props.roleid,
|
|
139
|
+
);
|
|
134
140
|
|
|
135
141
|
/**
|
|
136
|
-
* ⚠️ PVE
|
|
137
|
-
*
|
|
138
|
-
* forever, for every grant this provider did not create itself.
|
|
142
|
+
* ⚠️ PVE answers `propagate` as 1/0 rather than true/false, and OMITS it when it carries the API
|
|
143
|
+
* default, which is ON. distilled's generated schema types it `unknown` for the same reason.
|
|
139
144
|
*/
|
|
140
|
-
const propagates = (row:
|
|
141
|
-
const value = row
|
|
145
|
+
const propagates = (row: access.ListAccessAclResponseBodyItem) => {
|
|
146
|
+
const value = row.propagate;
|
|
142
147
|
return value === undefined || value === 1 || value === true || value === '1';
|
|
143
148
|
};
|
|
144
149
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
* privilege set is Datastore.Audit, Mapping.Audit, Pool.Audit, SDN.Audit, Sys.Audit,
|
|
174
|
-
* VM.Audit, VM.GuestAgent.Audit — MEASURED in the 2026-09-13 cluster read. No
|
|
175
|
-
* Permissions.Modify. So on the default lease all fourteen live grants read back unbound,
|
|
176
|
-
* `matches` is false for every one, the plan reports fourteen updates for grants that
|
|
177
|
-
* plainly exist, and the deploy PUTs each one before acl.ts's own read-back guard dies.
|
|
178
|
-
* ★ THIS IS THE SAME CLASS AS storage.ts, sdn-zone.ts, sdn-vnet.ts AND api-token.ts, and it is
|
|
179
|
-
* the worst instance of it: those three are gated and answer 403, which is at least an
|
|
180
|
-
* error. A filtered endpoint answers success with less data, which is indistinguishable from
|
|
181
|
-
* the object not being there.
|
|
182
|
-
*/
|
|
183
|
-
readRole: 'provision',
|
|
184
|
-
collection: () => 'access/acl',
|
|
185
|
-
createForm: bind,
|
|
186
|
-
/**
|
|
187
|
-
* ⚠️ ONLY `propagate` IS COMPARED, AND ONLY ONCE THE GRANT IS THERE. path/type/ugid/roleid are
|
|
188
|
-
* the FILTER that produced these attributes, not a reading of the cluster: comparing them with
|
|
189
|
-
* the props they came from is true by construction. An identity change is a DIFFERENT grant,
|
|
190
|
-
* which `diff` in the provider handles.
|
|
191
|
-
*/
|
|
192
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
193
|
-
endpoint: { create: 'pve:PUT /access/acl', update: 'pve:PUT /access/acl' },
|
|
194
|
-
matches: (attributes, props) =>
|
|
195
|
-
attributes.bound && attributes.propagate === (props.propagate !== false),
|
|
196
|
-
path: () => 'access/acl',
|
|
197
|
-
updateForm: bind,
|
|
198
|
-
});
|
|
150
|
+
const attributesOf = (
|
|
151
|
+
rows: readonly access.ListAccessAclResponseBodyItem[],
|
|
152
|
+
props: AclProps,
|
|
153
|
+
): AclAttributes => {
|
|
154
|
+
const row = find(rows, props);
|
|
155
|
+
return {
|
|
156
|
+
bound: row !== undefined,
|
|
157
|
+
path: normalize(props.path),
|
|
158
|
+
propagate: row !== undefined && propagates(row),
|
|
159
|
+
roleid: props.roleid,
|
|
160
|
+
type: props.type,
|
|
161
|
+
ugid: props.ugid,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const matches = (attributes: AclAttributes, props: AclProps) =>
|
|
166
|
+
attributes.bound && attributes.propagate === (props.propagate !== false);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
|
|
170
|
+
* ⛔ on filtering. `read`/`orElseSucceed` folds ANY failure (network, a genuine permission
|
|
171
|
+
* error) into "unknown"; a 200 with an empty or non-matching list is a real answer, not folded.
|
|
172
|
+
*/
|
|
173
|
+
const readAttributes = (props: AclProps) =>
|
|
174
|
+
runPve(props.target, 'provision', false, access.listAccessAcl({})).pipe(
|
|
175
|
+
Effect.map((rows) => attributesOf(rows, props)),
|
|
176
|
+
Effect.orElseSucceed(() => undefined),
|
|
177
|
+
);
|
|
199
178
|
|
|
200
179
|
export const ProxmoxAclProvider = () =>
|
|
201
180
|
Provider.effect(
|
|
202
181
|
ProxmoxAcl,
|
|
203
182
|
Effect.succeed(
|
|
204
183
|
ProxmoxAcl.Provider.of({
|
|
205
|
-
/**
|
|
206
|
-
* ⛔ EMPTY LIKE EVERY OTHER RESOURCE HERE, AND MOST OF ALL THIS ONE. `GET /access/acl`
|
|
207
|
-
* hands back every grant a human ever clicked; adopting them would let a later plan
|
|
208
|
-
* DELETE somebody's access as tidy-up. Adoption is an explicit act.
|
|
209
|
-
*/
|
|
184
|
+
/** ⛔ EMPTY LIKE EVERY OTHER PVE RESOURCE. Adopting every grant a human ever clicked is not adoption. */
|
|
210
185
|
list: () => Effect.succeed([]),
|
|
211
186
|
read: Effect.fn(function* ({ olds }) {
|
|
212
|
-
return yield*
|
|
187
|
+
return yield* readAttributes(olds);
|
|
213
188
|
}),
|
|
214
189
|
/**
|
|
215
|
-
* ⛔ AN IDENTITY CHANGE IS A REPLACE
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* props, which is the only thing that removes it.
|
|
221
|
-
* ⚠️ CREATE-FIRST, DELIBERATELY (Alchemy's default; `deleteFirst: true` would invert it).
|
|
222
|
-
* Delete-first could remove the very grant the provision credential mints against and
|
|
223
|
-
* leave nothing able to put it back. The cost is that a NARROWING change leaves the
|
|
224
|
-
* wider grant bound until Phase 2 collects the old generation.
|
|
190
|
+
* ⛔ AN IDENTITY CHANGE IS A REPLACE. Editing `roleid` (or the subject, or the path) would
|
|
191
|
+
* PUT the NEW grant and LEAVE THE OLD ONE BOUND; replace makes Alchemy `delete` the old
|
|
192
|
+
* props first, the only thing that removes it. Create-first (Alchemy's default) is
|
|
193
|
+
* deliberate: delete-first could remove the very grant the provision credential mints
|
|
194
|
+
* against.
|
|
225
195
|
*/
|
|
226
196
|
diff: Effect.fn(function* ({ news, output }) {
|
|
227
197
|
if (output !== undefined && isResolved(news) && identity(news) !== identity(output)) {
|
|
228
198
|
return { action: 'replace' } as const;
|
|
229
199
|
}
|
|
230
|
-
|
|
200
|
+
if (!isResolved(news)) return undefined;
|
|
201
|
+
yield* guardWrite(news);
|
|
202
|
+
if (output === undefined) return undefined;
|
|
203
|
+
const live = yield* readAttributes(news);
|
|
204
|
+
return live !== undefined && matches(live, news)
|
|
205
|
+
? ({ action: 'noop' } as const)
|
|
206
|
+
: ({ action: 'update' } as const);
|
|
231
207
|
}),
|
|
232
208
|
/**
|
|
233
|
-
* ⚠️ THE READ-BACK GUARD,
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* is recorded as a landed grant and the next plan reads noop over the gap.
|
|
209
|
+
* ⚠️ THE READ-BACK GUARD, FOR A FAMILY WHOSE "ABSENT" IS `bound: false`, NOT `undefined`.
|
|
210
|
+
* PVE answers 200 with `{"data":null}` on calls that did nothing: without this, a PUT
|
|
211
|
+
* that silently no-oped is recorded as a landed grant and the next plan reads noop over
|
|
212
|
+
* the gap.
|
|
238
213
|
*/
|
|
239
214
|
reconcile: Effect.fn(function* ({ news }) {
|
|
240
|
-
const
|
|
241
|
-
|
|
215
|
+
const before = yield* readAttributes(news);
|
|
216
|
+
yield* guardWrite(news);
|
|
217
|
+
if (before === undefined || !matches(before, news)) {
|
|
218
|
+
yield* runPve(news.target, 'provision', true, access.putAccessAcl(bind(news)));
|
|
219
|
+
}
|
|
220
|
+
const after = yield* readAttributes(news);
|
|
221
|
+
if (after === undefined || !after.bound) {
|
|
242
222
|
return yield* Effect.die(
|
|
243
223
|
new Error(
|
|
244
224
|
`access/acl: the write returned no error but ${identity(news)} is still not ` +
|
|
@@ -251,18 +231,16 @@ export const ProxmoxAclProvider = () =>
|
|
|
251
231
|
return after;
|
|
252
232
|
}),
|
|
253
233
|
/**
|
|
254
|
-
* ⛔ NOT
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* tuple is cluster-global — re-read the second ⛔ in the header before assuming this
|
|
261
|
-
* only takes away what this stack declared.
|
|
234
|
+
* ⛔ NOT A DESTROY CALL — THERE IS NONE. `DELETE /access/acl` is not implemented; removal
|
|
235
|
+
* is the same PUT with `delete=1` and the identity tuple, no `propagate`, because
|
|
236
|
+
* removal is keyed on the tuple and nothing else.
|
|
237
|
+
* ⚠️ IDEMPOTENT, AND CLUSTER-WIDE: removing a grant that is already gone is a no-op, but
|
|
238
|
+
* the tuple is shared — re-read the header's second ⛔ before assuming this only takes
|
|
239
|
+
* away what this stack declared.
|
|
262
240
|
*/
|
|
263
241
|
delete: Effect.fn(function* ({ olds }) {
|
|
264
|
-
const removal = { delete: '1'
|
|
265
|
-
yield*
|
|
242
|
+
const removal: access.PutAccessAclRequest = { ...tuple(olds), delete: '1' };
|
|
243
|
+
yield* runPve(olds.target, 'provision', true, access.putAccessAcl(removal));
|
|
266
244
|
}),
|
|
267
245
|
}),
|
|
268
246
|
),
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster member failover for `runPveWith`/`runPve`, over the REAL `@distilled.cloud/proxmox`
|
|
3
|
+
* protocol (real path assembly, real form encoding, real `{"data":...}` unwrap) instead of a
|
|
4
|
+
* hand-built request — the distilled-backed counterpart to client.test.ts, same servers, same
|
|
5
|
+
* rule, same helper shape. members.test.ts still owns the pre-send/any-transport classification
|
|
6
|
+
* itself; this file only proves `runPveWith` obeys it when the request comes from the SDK.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { describe, it } from 'node:test';
|
|
10
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
14
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
15
|
+
import type { PveCredential, PveTarget } from './credentials.ts';
|
|
16
|
+
import { PveClusterExhausted, runPveWith } from './distilled-pve.ts';
|
|
17
|
+
import { orderedMembers, resetLastGoodForTest } from './members.ts';
|
|
18
|
+
|
|
19
|
+
type ServeOptions = {
|
|
20
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
21
|
+
hostname: string;
|
|
22
|
+
port: number;
|
|
23
|
+
};
|
|
24
|
+
const { Bun } = globalThis as unknown as {
|
|
25
|
+
Bun: { serve(options: ServeOptions): { port: number; stop(closeActive: boolean): void } };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const CRED: PveCredential = {
|
|
29
|
+
leaseSeconds: 300,
|
|
30
|
+
secret: 'not-a-real-secret',
|
|
31
|
+
tokenId: 'hf-read@pve!fake',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const target = (members: readonly string[]): PveTarget => ({
|
|
35
|
+
members,
|
|
36
|
+
mount: 'proxmox-c1-test',
|
|
37
|
+
scheme: 'pve',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Same remap as client.test.ts: https://member:8006 -> http://127.0.0.1:<port>. Never a live host. */
|
|
41
|
+
const remapFetch = (ports: Record<string, number>, underlying: typeof fetch = globalThis.fetch) =>
|
|
42
|
+
Object.assign(
|
|
43
|
+
(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
44
|
+
const href =
|
|
45
|
+
typeof input === 'string'
|
|
46
|
+
? input
|
|
47
|
+
: input instanceof URL
|
|
48
|
+
? input.href
|
|
49
|
+
: input instanceof Request
|
|
50
|
+
? input.url
|
|
51
|
+
: String(input);
|
|
52
|
+
const url = new URL(href);
|
|
53
|
+
const port = ports[url.hostname];
|
|
54
|
+
if (port !== undefined) {
|
|
55
|
+
url.protocol = 'http:';
|
|
56
|
+
url.hostname = '127.0.0.1';
|
|
57
|
+
url.port = String(port);
|
|
58
|
+
return underlying(url, init);
|
|
59
|
+
}
|
|
60
|
+
return Promise.reject(
|
|
61
|
+
Object.assign(new Error(`getaddrinfo ENOTFOUND ${url.hostname}`), { code: 'ENOTFOUND' }),
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
{ preconnect: globalThis.fetch.preconnect },
|
|
65
|
+
) as typeof fetch;
|
|
66
|
+
|
|
67
|
+
const layerFor = (ports: Record<string, number>) =>
|
|
68
|
+
FetchHttpClient.layer.pipe(
|
|
69
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, remapFetch(ports))),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const run = <A, E>(
|
|
73
|
+
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
|
|
74
|
+
ports: Record<string, number> = {},
|
|
75
|
+
) => Effect.runPromise(effect.pipe(Effect.provide(layerFor(ports))));
|
|
76
|
+
|
|
77
|
+
/** A fake member answering PVE's own `{"data": ...}` envelope, counting its requests. */
|
|
78
|
+
const counting = (data: unknown, status = 200) => {
|
|
79
|
+
let hits = 0;
|
|
80
|
+
const server = Bun.serve({
|
|
81
|
+
fetch: () => {
|
|
82
|
+
hits += 1;
|
|
83
|
+
return Response.json({ data }, { status });
|
|
84
|
+
},
|
|
85
|
+
hostname: '127.0.0.1',
|
|
86
|
+
port: 0,
|
|
87
|
+
});
|
|
88
|
+
return { hits: () => hits, port: server.port, stop: () => server.stop(true) };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const A = 'pve-a.test';
|
|
92
|
+
const B = 'pve-b.test';
|
|
93
|
+
const readAcl = access.listAccessAcl({});
|
|
94
|
+
const writeAcl = access.putAccessAcl({ path: '/', roles: 'PVEAuditor', users: 'a@pve' });
|
|
95
|
+
|
|
96
|
+
describe('runPveWith: cluster member failover over the distilled protocol', () => {
|
|
97
|
+
it('skips an unresolvable first member and answers from the second', async () => {
|
|
98
|
+
resetLastGoodForTest();
|
|
99
|
+
const up = counting([{ path: '/', roleid: 'PVEAuditor', type: 'user', ugid: 'a@pve' }]);
|
|
100
|
+
try {
|
|
101
|
+
const rows = await run(runPveWith(target([A, B]), CRED, false, readAcl), { [B]: up.port });
|
|
102
|
+
assert.equal(rows.length, 1);
|
|
103
|
+
} finally {
|
|
104
|
+
up.stop();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not fail over on an HTTP answer (a typed error) from the first member', async () => {
|
|
109
|
+
resetLastGoodForTest();
|
|
110
|
+
const bad = Bun.serve({
|
|
111
|
+
fetch: () =>
|
|
112
|
+
Response.json({ data: null, message: 'Permission check failed' }, { status: 403 }),
|
|
113
|
+
hostname: '127.0.0.1',
|
|
114
|
+
port: 0,
|
|
115
|
+
});
|
|
116
|
+
const good = counting([]);
|
|
117
|
+
try {
|
|
118
|
+
const error = await run(runPveWith(target([A, B]), CRED, false, readAcl).pipe(Effect.flip), {
|
|
119
|
+
[A]: bad.port,
|
|
120
|
+
[B]: good.port,
|
|
121
|
+
});
|
|
122
|
+
assert.equal((error as { _tag?: string })._tag, 'Forbidden');
|
|
123
|
+
assert.equal(good.hits(), 0);
|
|
124
|
+
} finally {
|
|
125
|
+
bad.stop(true);
|
|
126
|
+
good.stop();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('tries the last-good member first on the next call', async () => {
|
|
131
|
+
resetLastGoodForTest();
|
|
132
|
+
const serverA = counting([]);
|
|
133
|
+
const serverB = counting([]);
|
|
134
|
+
try {
|
|
135
|
+
const t = target([A, B]);
|
|
136
|
+
const ports = { [A]: serverA.port, [B]: serverB.port };
|
|
137
|
+
await run(runPveWith(t, CRED, false, readAcl), ports);
|
|
138
|
+
assert.equal(serverA.hits(), 1);
|
|
139
|
+
assert.equal(serverB.hits(), 0);
|
|
140
|
+
assert.deepEqual(orderedMembers(t), [A, B]);
|
|
141
|
+
await run(runPveWith(t, CRED, false, access.listAccessAcl({})), ports);
|
|
142
|
+
assert.equal(serverA.hits(), 2);
|
|
143
|
+
assert.equal(serverB.hits(), 0);
|
|
144
|
+
} finally {
|
|
145
|
+
serverA.stop();
|
|
146
|
+
serverB.stop();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('does not re-send a write after a transport timeout', async () => {
|
|
151
|
+
resetLastGoodForTest();
|
|
152
|
+
const hang = Bun.serve({
|
|
153
|
+
fetch: () => new Promise<Response>(() => {}),
|
|
154
|
+
hostname: '127.0.0.1',
|
|
155
|
+
port: 0,
|
|
156
|
+
});
|
|
157
|
+
const backup = counting({});
|
|
158
|
+
try {
|
|
159
|
+
await run(
|
|
160
|
+
runPveWith(target([A, B]), CRED, true, writeAcl).pipe(
|
|
161
|
+
Effect.timeout('50 millis'),
|
|
162
|
+
Effect.flip,
|
|
163
|
+
),
|
|
164
|
+
{ [A]: hang.port, [B]: backup.port },
|
|
165
|
+
);
|
|
166
|
+
// ⛔ B must never receive the PUT — a post-send timeout is not pre-send failover.
|
|
167
|
+
assert.equal(backup.hits(), 0);
|
|
168
|
+
} finally {
|
|
169
|
+
hang.stop(true);
|
|
170
|
+
backup.stop();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('fails a write over when the first member refuses the connection', async () => {
|
|
175
|
+
resetLastGoodForTest();
|
|
176
|
+
const backup = counting({});
|
|
177
|
+
try {
|
|
178
|
+
// Loopback port 1 is closed: Bun answers ConnectionRefused before a byte is written.
|
|
179
|
+
await run(runPveWith(target([A, B]), CRED, true, writeAcl), { [A]: 1, [B]: backup.port });
|
|
180
|
+
assert.equal(backup.hits(), 1);
|
|
181
|
+
} finally {
|
|
182
|
+
backup.stop();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('names every member when all are down', async () => {
|
|
187
|
+
resetLastGoodForTest();
|
|
188
|
+
const error = await run(runPveWith(target([A, B]), CRED, false, readAcl).pipe(Effect.flip));
|
|
189
|
+
assert.ok(error instanceof PveClusterExhausted);
|
|
190
|
+
assert.match(error.message, /all members failed/);
|
|
191
|
+
assert.match(error.message, new RegExp(A));
|
|
192
|
+
assert.match(error.message, new RegExp(B));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ⛔ UNLIKE THE 'transport timeout' TEST ABOVE, NO OUTER `Effect.timeout` HERE. That test's outer
|
|
197
|
+
* 50ms wrapper wins the race regardless of whether `runPveWith` bounds an attempt itself — it
|
|
198
|
+
* would still pass with no internal bound at all. These inject the bound INTO `runPveWith`
|
|
199
|
+
* (never done in production, which always uses the real `MEMBER_TIMEOUT`), so a regression
|
|
200
|
+
* hangs node:test's own per-test timeout instead of quietly passing. See members-timeout.test.ts
|
|
201
|
+
* for the members.ts-level twin.
|
|
202
|
+
* ⚠️ EXPLICIT `{ timeout: 15000 }`: bun's default per-test kill is 5000ms (MEASURED), too close
|
|
203
|
+
* to this shared, often-loaded dev machine's own jitter to trust for a 100ms-bounded call.
|
|
204
|
+
*/
|
|
205
|
+
it(
|
|
206
|
+
'a hung read fails over to the next member, within the injected bound alone',
|
|
207
|
+
{ timeout: 15000 },
|
|
208
|
+
async () => {
|
|
209
|
+
resetLastGoodForTest();
|
|
210
|
+
const hang = Bun.serve({
|
|
211
|
+
fetch: () => new Promise<Response>(() => {}),
|
|
212
|
+
hostname: '127.0.0.1',
|
|
213
|
+
port: 0,
|
|
214
|
+
});
|
|
215
|
+
const good = counting([{ path: '/', roleid: 'PVEAuditor', type: 'user', ugid: 'a@pve' }]);
|
|
216
|
+
try {
|
|
217
|
+
const started = Date.now();
|
|
218
|
+
const rows = await run(runPveWith(target([A, B]), CRED, false, readAcl, '100 millis'), {
|
|
219
|
+
[A]: hang.port,
|
|
220
|
+
[B]: good.port,
|
|
221
|
+
});
|
|
222
|
+
assert.equal(rows.length, 1);
|
|
223
|
+
assert.equal(good.hits(), 1);
|
|
224
|
+
assert.ok(Date.now() - started < 10000);
|
|
225
|
+
} finally {
|
|
226
|
+
hang.stop(true);
|
|
227
|
+
good.stop();
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
});
|