@homeflare/alchemy 0.31.1 → 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.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +44 -5
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +232 -229
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +34 -5
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +6 -6
- package/dist/proxmox/user.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.ts +10 -4
- package/src/proxmox/api-token.ts +20 -12
- package/src/proxmox/group-wire.ts +62 -8
- package/src/proxmox/group.ts +23 -35
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role.ts +9 -2
- package/src/proxmox/user-wire.ts +44 -8
- package/src/proxmox/user.ts +29 -27
- package/src/verify/fake-engine.ts +23 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/proxmox/role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAa7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAStE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;
|
|
1
|
+
{"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/proxmox/role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAa7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAStE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAwBjE,eAAO,MAAM,mBAAmB,iIA+E7B,CAAC"}
|
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.User`'s wire shape: PVE's create/update forms
|
|
3
|
-
*
|
|
4
|
-
* under the 250-line cap — the api-token.ts/api-token-form.ts seam.
|
|
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
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
7
8
|
import type { UserAttributes, UserProps } from './user.ts';
|
|
9
|
+
import { UNREADABLE, type Unreadable } from './unreadable-read.ts';
|
|
8
10
|
export declare const USER_CREATE = "pve:POST /access/users";
|
|
9
11
|
export declare const USER_UPDATE = "pve:PUT /access/users/{userid}";
|
|
10
12
|
export declare const createForm: (props: UserProps) => access.CreateAccessUserRequest;
|
|
11
13
|
export declare const updateForm: (props: UserProps) => access.PutAccessUserRequest;
|
|
12
|
-
export declare const attributesOf: (live: access.GetAccessUserResponse, props: UserProps) => UserAttributes;
|
|
13
14
|
/**
|
|
14
15
|
* ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
|
|
15
16
|
* what a PUT can put back; a field PVE reports but will not accept is drift nobody can fix.
|
|
16
17
|
*/
|
|
17
18
|
export declare const matches: (attributes: UserAttributes, props: UserProps) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* ★ Default `read` role for this family — only the write side needs `provision`.
|
|
21
|
+
* ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
|
|
22
|
+
* kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
|
|
23
|
+
* key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
|
|
24
|
+
* family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
|
|
25
|
+
* JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
|
|
26
|
+
* fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
|
|
27
|
+
* (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
|
|
28
|
+
* which `runPveWith` retries across every member and exhausts identically —
|
|
29
|
+
* `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
|
|
30
|
+
* false `update` on every account.
|
|
31
|
+
*/
|
|
32
|
+
export declare const readUserOrFail: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
33
|
+
/**
|
|
34
|
+
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
|
|
35
|
+
* missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
|
|
36
|
+
* "absent" from a failure to ever run — without this fold, a brand-new account could never be
|
|
37
|
+
* created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
|
|
38
|
+
* which PVE refuses loudly rather than silently corrupting anything.
|
|
39
|
+
* ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
|
|
40
|
+
* folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
|
|
41
|
+
* nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
|
|
42
|
+
* propagates and fails the whole plan loudly instead.
|
|
43
|
+
*/
|
|
44
|
+
export declare const readUser: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes | undefined, never, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
45
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
46
|
+
export declare const dropUnreadable: (live: UserAttributes | Unreadable | undefined) => UserAttributes | undefined;
|
|
18
47
|
//# sourceMappingURL=user-wire.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/user-wire.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,KAAK,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"user-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/user-wire.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAC1D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,eAAO,MAAM,WAAW,2BAA2B,CAAC;AACpD,eAAO,MAAM,WAAW,mCAAmC,CAAC;AA2D5D,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,uBAGnD,CAAC;AAEH,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,oBAGnD,CAAC;AAoBH;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,cAAc,SAAS,SAAS,YAOF,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,UAAW,SAAS,g2BAG+C,CAAC;AAE/F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,UAAW,SAAS,+HAC0B,CAAC;AAEpE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,cAAc,GAAG,UAAU,GAAG,SAAS,+BACpC,CAAC"}
|
package/dist/proxmox/user.d.ts
CHANGED
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
* user, so a role wide enough to edit the account can still be refused for one group.
|
|
34
34
|
* · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
|
|
35
35
|
*
|
|
36
|
-
* ⚠️ A MISSING USER IS A 500, NOT A 404 —
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
36
|
+
* ⚠️ A MISSING USER IS A 500, NOT A 404 — MEASURED against the live cluster, "no such user
|
|
37
|
+
* ('x@pve')". distilled turns that into an `InternalServerError`, with no distinct "not found"
|
|
38
|
+
* tag to catch. `user-wire.ts`'s `readUser` (used by `read`/`reconcile`, where the create
|
|
39
|
+
* workflow needs "absent" from exactly this failure) still folds it; `readUserOrFail` (used by
|
|
40
|
+
* `diff` below) does not — see that function's own header for why folding it there was the
|
|
41
|
+
* cries-wolf bug all over again, just for a genuine PVE quirk instead of a permission denial.
|
|
42
42
|
*
|
|
43
43
|
* ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
|
|
44
44
|
* (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AActE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mGAAmG;IACnG,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAEjE,eAAO,MAAM,mBAAmB,iIAgG7B,CAAC"}
|
|
@@ -48,11 +48,25 @@ export declare const writesOf: (cloud: Cloud) => string[];
|
|
|
48
48
|
export declare const thingProviders: (cloud: Cloud) => Layer.Layer<Alchemy.Provider<Blind> | Alchemy.Provider<Recorded> | Alchemy.Provider<Thing>, never, never>;
|
|
49
49
|
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
|
50
50
|
export type Body = Effect.Effect<unknown, unknown, unknown>;
|
|
51
|
+
/** One row of `Alchemy.Drift.detect`'s dry-run report — the fields `drift` below asserts on. */
|
|
52
|
+
export type DriftRow = {
|
|
53
|
+
readonly action: string;
|
|
54
|
+
};
|
|
51
55
|
export interface FakeEngine {
|
|
52
56
|
/** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
|
|
53
57
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
54
58
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
55
59
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
60
|
+
/**
|
|
61
|
+
* `alchemy drift`'s own dry-run (`Alchemy.Drift.detect`) over whatever `deploy` already
|
|
62
|
+
* persisted — `body` is used only to resolve the stack's name/stage/providers, exactly as
|
|
63
|
+
* `verify` does; drift itself reads the state store, not `body`'s declarations. ⚠️ A DIFFERENT
|
|
64
|
+
* ENGINE PATH FROM `verify`'s `--all` READ, proxmox/read-failure-propagation.test.ts's own ⛔:
|
|
65
|
+
* `Drift.ts` calls a provider's `read` with `output` set to the PERSISTED attributes, and folds
|
|
66
|
+
* a `read` that resolves `undefined` into `{action: 'missing'}` with no error — silent, unlike
|
|
67
|
+
* `verify.ts`'s `readWithState`, which already wraps that same call in `Effect.catchCause`.
|
|
68
|
+
*/
|
|
69
|
+
readonly drift: (body: Body) => Promise<Record<string, DriftRow>>;
|
|
56
70
|
/**
|
|
57
71
|
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
58
72
|
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,KAAG,
|
|
1
|
+
{"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,gGAAgG;AAChG,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnD,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAClE;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,KAAG,UAmD3E,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,UAA+C,CAAC"}
|
package/docs/grafana.md
CHANGED
|
@@ -8,32 +8,61 @@ publicly at `tesla.homeflare.dev/dash/`.
|
|
|
8
8
|
|
|
9
9
|
## The SDK gap — why only `Datasource` ships
|
|
10
10
|
|
|
11
|
+
✅ **SDK-level gap fixed 2026-09-24** (`@distilled.cloud/grafana`, aliased onto
|
|
12
|
+
`@homeflare/distilled-grafana` — 0.1.0 pre-release, 0.2.0 once its `minor` changeset publishes it;
|
|
13
|
+
see [docs/distilled-interim.md](./distilled-interim.md)).
|
|
14
|
+
The gap below is otherwise unchanged and left as evidence of what was measured and why; only its
|
|
15
|
+
status line moved. **Still true today:** only `Grafana.Datasource` ships as a house _resource_ —
|
|
16
|
+
`Grafana.Folder`, `Grafana.Dashboard`, `Grafana.AlertRule` and friends are follow-up work on top of
|
|
17
|
+
the now-available SDK operations, not part of this fix.
|
|
18
|
+
|
|
11
19
|
Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
|
|
12
|
-
`lib/services/grafana.d.ts`, 4,938 lines): the package
|
|
13
|
-
operations for folders, dashboards, alert rules or contact points**. It
|
|
20
|
+
`lib/services/grafana.d.ts`, 4,938 lines): the package had **no create/read/update/delete
|
|
21
|
+
operations for folders, dashboards, alert rules or contact points**. It covered datasources in
|
|
14
22
|
full (`addDataSource`, `getDataSourceByUID`, `updateDataSourceByUID`, `deleteDataSourceByUID`,
|
|
15
23
|
`getDataSources`), plus org/user/team/role/report/annotation/library-element/public-dashboard/
|
|
16
24
|
snapshot management — but:
|
|
17
25
|
|
|
18
|
-
- **Folders:** only `updateFolderPermissions`
|
|
19
|
-
`deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes
|
|
20
|
-
- **Dashboards:** only snapshot and public-dashboard routes
|
|
26
|
+
- **Folders:** only `updateFolderPermissions` existed. No `createFolder`, `getFolder` or
|
|
27
|
+
`deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes were entirely absent.
|
|
28
|
+
- **Dashboards:** only snapshot and public-dashboard routes existed (`createDashboardSnapshot`,
|
|
21
29
|
`getPublicDashboard`, …). No `POST /api/dashboards/db`, no `GET /api/dashboards/uid/{uid}`, no
|
|
22
|
-
`DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family
|
|
30
|
+
`DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family was absent.
|
|
23
31
|
- **Alert rules:** only `routeGetAlertRuleExport`/`routeGetAlertRuleGroupExport`/
|
|
24
|
-
`routeGetAlertRulesExport`
|
|
32
|
+
`routeGetAlertRulesExport` existed — export (read-only) endpoints under `/api/v1/provisioning/`.
|
|
25
33
|
No create/update/delete for `/api/v1/provisioning/alert-rules{,/{uid}}`.
|
|
26
|
-
- **Contact points:** only `routeGetContactpointsExport`
|
|
34
|
+
- **Contact points:** only `routeGetContactpointsExport` existed, same story — no
|
|
27
35
|
`/api/v1/provisioning/contact-points{,/{uid}}` CRUD.
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
**Root cause, measured (not the spec lacking the paths):** Grafana's own OpenAPI document has
|
|
38
|
+
every one of the routes above — the converter dropped them. `skipDeprecated: true` (the distilled
|
|
39
|
+
converter's v0 default) skips any operation the spec marks `deprecated: true`, and Grafana marks
|
|
40
|
+
its entire classic folder/dashboard/alerting-provisioning WRITE surface deprecated, each
|
|
41
|
+
operation's `description` pointing at a `/apis/<group>.grafana.app/v1/...` Kubernetes-apiserver
|
|
42
|
+
route that is a separate API server with its own OpenAPI document — absent from this spec
|
|
43
|
+
entirely, and not what this family's `Grafana.Datasource` (or any planned `Grafana.Folder`/
|
|
44
|
+
`Grafana.Dashboard`/etc.) targets. The fix patches `deprecated` back to `false` on exactly the 32
|
|
45
|
+
operations this gap named (leaving the other 32 deprecated operations — playlists, org/user
|
|
46
|
+
preferences, datasources-by-name, licensing, reports, dashboard stars/tags/versions — excluded, as
|
|
47
|
+
before): see the distilled clone's `packages/grafana/scripts/convert.ts` header and
|
|
48
|
+
`patches/*.patch.json` for the exact list and reasoning, and
|
|
49
|
+
`packages/distilled-grafana/README.md` in this kit for the copied result. `Grafana.Datasource`'s
|
|
50
|
+
own operations (`addDataSource`/`getDataSourceByUID`/`updateDataSourceByUID`/
|
|
51
|
+
`deleteDataSourceByUID`) were never deprecated and are untouched — same props, same attributes,
|
|
52
|
+
same generated code, confirmed by diffing the distilled clone's regenerated output against its
|
|
53
|
+
prior commit operation-by-operation.
|
|
54
|
+
|
|
55
|
+
This was recorded in [`docs/upstream-conformance.md`](./upstream-conformance.md) rather than worked
|
|
30
56
|
around. Per S23, the fix is a distilled PR (regenerate against a spec that includes these routes,
|
|
31
|
-
or patch them in per S22)
|
|
32
|
-
pieces alone
|
|
33
|
-
client, exactly what S23 exists to prevent.
|
|
34
|
-
|
|
35
|
-
`
|
|
36
|
-
|
|
57
|
+
or patch them in per S22) — not a hand-rolled `HttpClient` client in this package for the missing
|
|
58
|
+
pieces alone, which would put half a family through the SDK and half through a second, parallel
|
|
59
|
+
client, exactly what S23 exists to prevent. It shipped as a kit interim package
|
|
60
|
+
(`@homeflare/distilled-grafana`), the same S22 route `@homeflare/distilled-netbox` established,
|
|
61
|
+
because `alchemy-run/distilled` is upstream and this house never pushes to it (decision 42) — see
|
|
62
|
+
docs/distilled-interim.md.
|
|
63
|
+
|
|
64
|
+
`teslamate-grafana` itself is consistent with this gap having been real, not merely unexploited:
|
|
65
|
+
its one datasource is API-manageable and already exists; its dashboards ship baked into the
|
|
37
66
|
`teslamate/grafana` image and are provisioned by the container, not by any API call; it has no
|
|
38
67
|
folders, alert rules or contact points configured at all (measured over ssh, read-only, 2026-09-24).
|
|
39
68
|
|
|
@@ -96,7 +125,8 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
|
|
|
96
125
|
|
|
97
126
|
## Not covered
|
|
98
127
|
|
|
99
|
-
- **Folders, dashboards, alert rules, contact points
|
|
100
|
-
|
|
128
|
+
- **Folders, dashboards, alert rules, contact points, notification policies, mute timings.** The
|
|
129
|
+
SDK-level blocker is fixed (above); no house `Resource` calls these operations yet — follow-up
|
|
130
|
+
work, not blocked on anything.
|
|
101
131
|
- **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
|
|
102
132
|
still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
|
|
@@ -249,10 +249,20 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
249
249
|
`GET/DELETE /dashboards/uid/{uid}`), alert rules or contact points (both have only a
|
|
250
250
|
`routeGet*Export` read-only route under `/v1/provisioning/`, no create/update/delete). S1
|
|
251
251
|
found no upstream `Alchemy` family for any of these either. Full detail:
|
|
252
|
-
[grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships).
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
252
|
+
[grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships).
|
|
253
|
+
|
|
254
|
+
✅ **S22 fixed 2026-09-24** (branch `claude2/distilled-grafana`): root cause measured to be
|
|
255
|
+
`skipDeprecated: true` dropping 64 operations Grafana's own spec marks `deprecated: true` in
|
|
256
|
+
favor of a separate, undocumented-here Kubernetes-apiserver route — not a spec omission. RFC-6902
|
|
257
|
+
patches un-deprecate exactly the 32 operations this finding named (folders, dashboards,
|
|
258
|
+
alerting-provisioning writes); the other 32 stay excluded. Shipped as
|
|
259
|
+
`@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
|
|
260
|
+
releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
|
|
261
|
+
`@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
|
|
262
|
+
are unchanged (diffed operation-by-operation against the prior commit). **Still open, and not
|
|
263
|
+
this PR's scope:** no `Grafana.Folder`/`Dashboard`/`AlertRule`/`ContactPoint`/
|
|
264
|
+
`NotificationPolicy`/`MuteTiming` resource calls the newly-available operations yet — follow-up
|
|
265
|
+
PRs, no longer blocked on the SDK.
|
|
256
266
|
- ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
|
|
257
267
|
(H1), and credentials come from an explicit env var NAME at call time rather than an
|
|
258
268
|
`alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
"dependencies": {
|
|
109
109
|
"@distilled.cloud/caddy": "npm:@homeflare/distilled-caddy@0.2.2",
|
|
110
|
+
"@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
|
|
110
111
|
"@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
|
|
111
112
|
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
|
|
112
113
|
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
|
|
@@ -118,7 +119,6 @@
|
|
|
118
119
|
"@distilled.cloud/discord": "1.0.0-rc.12",
|
|
119
120
|
"@distilled.cloud/forgejo": "1.0.0-rc.12",
|
|
120
121
|
"@distilled.cloud/google-workspace": "1.0.0-rc.12",
|
|
121
|
-
"@distilled.cloud/grafana": "1.0.0-rc.12",
|
|
122
122
|
"@effect/platform-node": "4.0.0-rc.115",
|
|
123
123
|
"@effect/sql-pg": "4.0.0-rc.115",
|
|
124
124
|
"alchemy": "2.0.0-beta.79",
|
package/src/grafana/index.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* the symbols a real stack consumes; the rest of the files (resource.ts's engine, fake-grafana.ts)
|
|
6
6
|
* are internals a provider needs but a consumer should not depend on.
|
|
7
7
|
*
|
|
8
|
-
* ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana` (aliased onto
|
|
9
|
+
* `@homeflare/distilled-grafana` — see docs/distilled-interim.md) now HAS folder, dashboard and
|
|
10
|
+
* alerting-provisioning CRUD operations — the SDK-level gap docs/grafana.md and
|
|
11
|
+
* docs/upstream-conformance.md recorded is fixed. Building `Grafana.Folder`, `Grafana.Dashboard`,
|
|
12
|
+
* `Grafana.AlertRule` etc. on top of them is follow-up work this PR deliberately does not do —
|
|
13
|
+
* see docs/grafana.md for what changed and what is still open.
|
|
13
14
|
*/
|
|
14
15
|
export {
|
|
15
16
|
type DatasourceAttributes,
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -99,15 +99,21 @@ export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
|
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
101
|
* ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
|
|
102
|
-
* ⛔ on filtering.
|
|
102
|
+
* ⛔ on filtering.
|
|
103
103
|
* ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
|
|
104
|
-
* "cries wolf" bug — comes back `UNREADABLE` instead of the
|
|
105
|
-
*
|
|
104
|
+
* "cries wolf" bug — comes back `UNREADABLE` instead of the `undefined` a genuine, SUCCESSFUL
|
|
105
|
+
* read (the row simply not being in the list) produces, so `diff` below can tell them apart.
|
|
106
|
+
* ⛔ NO `orElseSucceed` HERE ANY MORE — found on 2026-09-24, the SAME bug class as the credential
|
|
107
|
+
* denial: it used to fold EVERY OTHER failure (a transport error, `PveClusterExhausted`, a
|
|
108
|
+
* decode failure) into `undefined` too, which then read exactly like a genuine absence and
|
|
109
|
+
* forced the same false `update`. Only a read that actually SUCCEEDS (and the grant is not in
|
|
110
|
+
* the list it returns) may mean absent now; every other failure propagates and fails the whole
|
|
111
|
+
* plan loudly — the engine's own contract for a failed `read`/`diff` (verify.ts's `readWithState`
|
|
112
|
+
* already expects and reports it as `'failed'`).
|
|
106
113
|
*/
|
|
107
114
|
const readAttributes = (props: AclProps) =>
|
|
108
115
|
readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
|
|
109
116
|
Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
|
|
110
|
-
Effect.orElseSucceed(() => undefined),
|
|
111
117
|
);
|
|
112
118
|
|
|
113
119
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
package/src/proxmox/api-token.ts
CHANGED
|
@@ -141,12 +141,22 @@ export const ProxmoxApiToken = Resource<ProxmoxApiToken>('Proxmox.ApiToken', {
|
|
|
141
141
|
defaultRemovalPolicy: 'retain',
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
/**
|
|
145
|
+
* ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges.
|
|
146
|
+
* ⛔ `{ userid, tokenid }`, NEVER THE WHOLE `props` — group.ts's readGroup has the full measured
|
|
147
|
+
* mechanism (kit 0.31.1's regression): an extra key beyond the schema's own path labels gets
|
|
148
|
+
* encoded onto this GET as a body, which a stricter fetch client refuses outright.
|
|
149
|
+
* ⛔ NO `orElseSucceed` — a MISSING token's absence signal is a SUCCESSFUL read with `expire`/
|
|
150
|
+
* `privsep` both absent (`attributesOf`/api-token-form.ts, MEASURED pre-migration), unlike
|
|
151
|
+
* user.ts/group.ts's missing-object 500 — so this family never needed their folding split. A
|
|
152
|
+
* THROWN failure here is genuinely unexpected and must propagate and fail the plan loudly.
|
|
153
|
+
*/
|
|
154
|
+
const readToken = (props: ApiTokenProps) => {
|
|
155
|
+
const label = { tokenid: props.tokenid, userid: props.userid };
|
|
156
|
+
return readOrUnreadable(
|
|
157
|
+
runPve(props.target, 'provision', false, access.getAccessUserToken(label)),
|
|
158
|
+
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
159
|
+
};
|
|
150
160
|
|
|
151
161
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
152
162
|
const dropUnreadable = (live: ApiTokenAttributes | Unreadable | undefined) =>
|
|
@@ -193,12 +203,10 @@ export const ProxmoxApiTokenProvider = () =>
|
|
|
193
203
|
}),
|
|
194
204
|
reconcile: Effect.fn(function* ({ news }) {
|
|
195
205
|
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
196
|
-
// read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// second is denied) and not silently wrong — the operator still sees a die() and goes
|
|
201
|
-
// looking — but named here rather than left for the next reader to assume away.
|
|
206
|
+
// read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race, not
|
|
207
|
+
// the routine case `diff` handles. Any OTHER read failure now propagates on its own
|
|
208
|
+
// (readToken no longer folds it), so the die() message below is reached only on a
|
|
209
|
+
// genuinely absent token, never on a transient failure wearing that message.
|
|
202
210
|
const before = dropUnreadable(yield* readToken(news));
|
|
203
211
|
if (before === undefined) {
|
|
204
212
|
return yield* Effect.die(
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.Group`'s wire shape: PVE's create/update forms
|
|
3
|
-
*
|
|
4
|
-
* the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
|
|
2
|
+
* `Proxmox.Group`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
|
|
3
|
+
* and the read itself. Split out of group.ts (2026-09-24) to keep that file under the 250-line
|
|
4
|
+
* cap — the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
|
|
5
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { runPve } from './distilled-pve.ts';
|
|
7
9
|
import type { GroupAttributes, GroupProps } from './group.ts';
|
|
10
|
+
import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
|
|
8
11
|
import { csv, text } from './values.ts';
|
|
9
12
|
|
|
10
13
|
export const GROUP_CREATE = 'pve:POST /access/groups';
|
|
@@ -41,10 +44,7 @@ export const updateForm = (props: GroupProps): access.PutAccessGroupRequest => (
|
|
|
41
44
|
groupid: props.groupid,
|
|
42
45
|
});
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
live: access.GetAccessGroupResponse,
|
|
46
|
-
props: GroupProps,
|
|
47
|
-
): GroupAttributes => ({
|
|
47
|
+
const attributesOf = (live: access.GetAccessGroupResponse, props: GroupProps): GroupAttributes => ({
|
|
48
48
|
/**
|
|
49
49
|
* ⚠️ ABSENT IS EMPTY, NOT MISSING — `read_group` sets `comment` only `if defined(…)`. distilled
|
|
50
50
|
* types it `comment?: string`, so `text()`'s `''` fallback meets the same normalisation.
|
|
@@ -61,3 +61,57 @@ export const attributesOf = (
|
|
|
61
61
|
*/
|
|
62
62
|
export const matches = (attributes: GroupAttributes, props: GroupProps) =>
|
|
63
63
|
attributes.comment === storedComment(props.comment);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ★ Default `read` role — nothing widened for this family, see group.ts's header privileges note.
|
|
67
|
+
* ⛔ `{ groupid: props.groupid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression
|
|
68
|
+
* in kit 0.31.1. `GetAccessGroupRequest`'s schema declares only `groupid` (a path label); any
|
|
69
|
+
* OTHER key on the object passed to `access.getAccessGroup` — `target`, `comment`, every field
|
|
70
|
+
* this family's own props carry — is treated by distilled's `buildRequest` as an "unknown key"
|
|
71
|
+
* and JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
|
|
72
|
+
* fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
|
|
73
|
+
* (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
|
|
74
|
+
* which `runPveWith` retries across every member and exhausts identically —
|
|
75
|
+
* `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
|
|
76
|
+
* false `update` on every group. `role.ts`'s list call and acl.ts's `listAccessAcl({})` were
|
|
77
|
+
* never at risk: both are always called with a literal `{}`.
|
|
78
|
+
* ⛔ `live.members === undefined` IS DEFENSIVE, NOT THE ABSENCE SIGNAL — CORRECTED 2026-09-24.
|
|
79
|
+
* This file previously claimed a missing group answers 200 with `{"data":null}`, unwrapped to
|
|
80
|
+
* `{}`. MEASURED against the live cluster: `GET /access/groups/{missing}` answers a genuine 500
|
|
81
|
+
* ("group '…' does not exist"), the SAME shape as user.ts's own measured 500 — there is no
|
|
82
|
+
* success-path absence signal here at all. The check stays as insurance against a future PVE
|
|
83
|
+
* release that does answer `{}`, but this function is what actually carries the "not found"
|
|
84
|
+
* case, as a thrown error — `readGroup` below is the one that folds it.
|
|
85
|
+
*/
|
|
86
|
+
export const readGroupOrFail = (props: GroupProps) =>
|
|
87
|
+
readOrUnreadable(
|
|
88
|
+
runPve(props.target, 'read', false, access.getAccessGroup({ groupid: props.groupid })),
|
|
89
|
+
).pipe(
|
|
90
|
+
Effect.map((live) =>
|
|
91
|
+
live === UNREADABLE
|
|
92
|
+
? UNREADABLE
|
|
93
|
+
: live.members === undefined
|
|
94
|
+
? undefined
|
|
95
|
+
: attributesOf(live, props),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT.
|
|
101
|
+
* PVE's OWN missing-group answer is a thrown 500 (the ⛔ above), so `reconcile`'s create branch
|
|
102
|
+
* NEEDS "absent" from a failure to ever run at all — without this fold, a brand-new group could
|
|
103
|
+
* never be created, because its very-first read would always fail loudly. Safe here because a
|
|
104
|
+
* wrongful fold costs at most a redundant `createAccessGroup` POST, which PVE refuses loudly
|
|
105
|
+
* ("group already exists") rather than silently corrupting anything.
|
|
106
|
+
* ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
|
|
107
|
+
* credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
|
|
108
|
+
* above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
|
|
109
|
+
* compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
|
|
110
|
+
* propagates and fails the whole plan loudly — never silently misreports one row.
|
|
111
|
+
*/
|
|
112
|
+
export const readGroup = (props: GroupProps) =>
|
|
113
|
+
readGroupOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
|
|
114
|
+
|
|
115
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
116
|
+
export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
117
|
+
live === UNREADABLE ? undefined : live;
|
package/src/proxmox/group.ts
CHANGED
|
@@ -76,19 +76,16 @@ import { guardWrite } from './distilled-guard.ts';
|
|
|
76
76
|
import {
|
|
77
77
|
GROUP_CREATE,
|
|
78
78
|
GROUP_UPDATE,
|
|
79
|
-
attributesOf,
|
|
80
79
|
createForm,
|
|
80
|
+
dropUnreadable,
|
|
81
81
|
matches,
|
|
82
|
+
readGroup,
|
|
83
|
+
readGroupOrFail,
|
|
82
84
|
updateForm,
|
|
83
85
|
} from './group-wire.ts';
|
|
84
86
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
85
87
|
import { runPve } from './distilled-pve.ts';
|
|
86
|
-
import {
|
|
87
|
-
UNREADABLE,
|
|
88
|
-
type Unreadable,
|
|
89
|
-
readOrUnreadable,
|
|
90
|
-
unreadableWarning,
|
|
91
|
-
} from './unreadable-read.ts';
|
|
88
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
92
89
|
|
|
93
90
|
export interface GroupProps extends WithTarget {
|
|
94
91
|
/**
|
|
@@ -127,31 +124,6 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
|
|
|
127
124
|
defaultRemovalPolicy: 'retain',
|
|
128
125
|
});
|
|
129
126
|
|
|
130
|
-
/**
|
|
131
|
-
* ★ Default `read` role — nothing widened for this family, see the header's privileges note.
|
|
132
|
-
* ⛔ `live.members === undefined` MEANS ABSENT, NOT AN EMPTY GROUP. `@distilled.cloud/core`'s
|
|
133
|
-
* REST protocol unwraps PVE's `{"data": null}` (a genuinely missing object — MEASURED in
|
|
134
|
-
* `protocol.ts`: "a Unit-output endpoint... `data` is null/absent") to `{}` BEFORE the
|
|
135
|
-
* schema-driven decode, not to a decode failure. `members` is the one REQUIRED field on this
|
|
136
|
-
* response, so its absence is the only signal that the group was never there — the same trap
|
|
137
|
-
* api-token-form.ts's `attributes` guards with its own required-field check.
|
|
138
|
-
*/
|
|
139
|
-
const readGroup = (props: GroupProps) =>
|
|
140
|
-
readOrUnreadable(runPve(props.target, 'read', false, access.getAccessGroup(props))).pipe(
|
|
141
|
-
Effect.map((live) =>
|
|
142
|
-
live === UNREADABLE
|
|
143
|
-
? UNREADABLE
|
|
144
|
-
: live.members === undefined
|
|
145
|
-
? undefined
|
|
146
|
-
: attributesOf(live, props),
|
|
147
|
-
),
|
|
148
|
-
Effect.orElseSucceed(() => undefined),
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
152
|
-
const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
153
|
-
live === UNREADABLE ? undefined : live;
|
|
154
|
-
|
|
155
127
|
export const ProxmoxGroupProvider = () =>
|
|
156
128
|
Provider.effect(
|
|
157
129
|
ProxmoxGroup,
|
|
@@ -162,15 +134,31 @@ export const ProxmoxGroupProvider = () =>
|
|
|
162
134
|
* cluster, `admins` (grants `Administrator` on `/`) included. Adoption stays explicit.
|
|
163
135
|
*/
|
|
164
136
|
list: () => Effect.succeed([]),
|
|
165
|
-
|
|
166
|
-
|
|
137
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
|
|
138
|
+
// of this very fix caught the gap. The engine calls this ONE hook from FOUR places
|
|
139
|
+
// (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
|
|
140
|
+
// cold-start adoption probe and interrupted-create recovery, and Apply.ts's
|
|
141
|
+
// delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
|
|
142
|
+
// `readGroup`'s fold is still needed there or a brand-new group could never be adopted
|
|
143
|
+
// or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
|
|
144
|
+
// it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
|
|
145
|
+
// `diff`'s own situation below — a transient failure folded to `undefined` there is
|
|
146
|
+
// reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
|
|
147
|
+
// command over. `readGroupOrFail` on that branch propagates it instead.
|
|
148
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
149
|
+
return dropUnreadable(
|
|
150
|
+
yield* output === undefined ? readGroup(olds) : readGroupOrFail(olds),
|
|
151
|
+
);
|
|
167
152
|
}),
|
|
168
153
|
diff: Effect.fn(function* ({ news, output }) {
|
|
169
154
|
if (!isResolved(news)) return undefined;
|
|
170
155
|
yield* guardWrite(GROUP_CREATE, createForm(news), output === undefined);
|
|
171
156
|
yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
|
|
172
157
|
if (output === undefined) return undefined;
|
|
173
|
-
|
|
158
|
+
// ⚠️ `readGroupOrFail`, NOT `readGroup` — see that function's own ⛔. A genuine
|
|
159
|
+
// TRANSIENT failure here propagates and fails the whole plan loudly instead of
|
|
160
|
+
// folding to "absent" and forcing a false update, the cries-wolf class of bug.
|
|
161
|
+
const live = yield* readGroupOrFail(news);
|
|
174
162
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
175
163
|
// `update` on a group that was plainly there — see unreadable-read.ts.
|
|
176
164
|
if (live === UNREADABLE) {
|