@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA
|
|
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"}
|
|
@@ -110,7 +110,7 @@ export declare const createForm: (props: ZfsPoolProps) => Record<string, string>
|
|
|
110
110
|
* here is the same `Effect.die` idiom as every other refusal in this package, and it costs
|
|
111
111
|
* nothing this pool did not already lack.
|
|
112
112
|
*/
|
|
113
|
-
export declare const createPool: <E, R>(props: ZfsPoolProps, collection: string, path: string, read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>) => Effect.Effect<ZfsPoolAttributes, E | Error, R | import("effect/unstable/http/HttpClient").HttpClient>;
|
|
113
|
+
export declare const createPool: <E, R>(props: ZfsPoolProps, collection: string, path: string, read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>) => Effect.Effect<ZfsPoolAttributes, E | Error | import("./credential-errors.ts").PveCredentialDenied, R | import("effect/unstable/http/HttpClient").HttpClient>;
|
|
114
114
|
/**
|
|
115
115
|
* ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
|
|
116
116
|
* S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
|
|
@@ -124,5 +124,5 @@ export declare const createPool: <E, R>(props: ZfsPoolProps, collection: string,
|
|
|
124
124
|
* resource reaching over to delete another's is how a stack ends up with state describing
|
|
125
125
|
* something that is gone.
|
|
126
126
|
*/
|
|
127
|
-
export declare const destroyPool: (olds: ZfsPoolProps, path: string) => Effect.Effect<unknown, Error, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
127
|
+
export declare const destroyPool: (olds: ZfsPoolProps, path: string) => Effect.Effect<unknown, Error | import("./credential-errors.ts").PveCredentialDenied, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
128
128
|
//# sourceMappingURL=zfs-pool-write.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,iKA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,+IACb,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,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Acl`'s wire shape: PVE's PUT form, identity, and how a live row becomes attributes.
|
|
3
|
+
* Split out of acl.ts (2026-09-24) to keep that file under the 250-line cap once the cries-wolf
|
|
4
|
+
* fix needed real space — the api-token.ts/api-token-form.ts seam, same reasoning: this file
|
|
5
|
+
* answers "what does PVE store", acl.ts answers "when has it changed" and drives the provider.
|
|
6
|
+
*/
|
|
7
|
+
import type * as access from '@distilled.cloud/proxmox/access';
|
|
8
|
+
import { guardForm } from './constraint-guard.ts';
|
|
9
|
+
import type { AclAttributes, AclProps } from './acl.ts';
|
|
10
|
+
|
|
11
|
+
/** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
|
|
12
|
+
export const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
|
|
13
|
+
|
|
14
|
+
/** The write names the subject with a plural key that differs per kind; the read answers singular. */
|
|
15
|
+
const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
|
|
16
|
+
|
|
17
|
+
/** The vendor endpoint every write and every constraint check runs against. */
|
|
18
|
+
const ENDPOINT = 'pve:PUT /access/acl';
|
|
19
|
+
|
|
20
|
+
/** The tuple PVE keys a grant by, as PUT's fields. */
|
|
21
|
+
export const tuple = (props: AclProps): access.PutAccessAclRequest => ({
|
|
22
|
+
path: normalize(props.path),
|
|
23
|
+
roles: props.roleid,
|
|
24
|
+
[SUBJECT_FIELD[props.type]]: props.ugid,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Create and update are one call: the tuple plus the single mutable field. */
|
|
28
|
+
export const bind = (props: AclProps): access.PutAccessAclRequest => ({
|
|
29
|
+
...tuple(props),
|
|
30
|
+
propagate: props.propagate === false ? '0' : '1',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** `guardForm` wants a plain string-valued form; distilled's request type carries `?` instead. */
|
|
34
|
+
const asForm = (body: access.PutAccessAclRequest): Record<string, string> =>
|
|
35
|
+
Object.fromEntries(Object.entries(body).filter((e): e is [string, string] => e[1] !== undefined));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ⚠️ ALWAYS PRESENCE-CHECKED. `bind`'s output IS the create form (there is no separate,
|
|
39
|
+
* narrower update form for this family — see acl.ts's header), so the vendor's
|
|
40
|
+
* required-parameter check is never wrong to run; the two separate create/update passes the
|
|
41
|
+
* generic factory ran elsewhere collapse to one call here without losing coverage.
|
|
42
|
+
*/
|
|
43
|
+
export const guardWrite = (props: AclProps) => guardForm(ENDPOINT, asForm(bind(props)), true);
|
|
44
|
+
|
|
45
|
+
/** What a change of this string means: not an edit, a different grant. */
|
|
46
|
+
export const identity = (grant: Pick<AclAttributes, 'path' | 'roleid' | 'type' | 'ugid'>) =>
|
|
47
|
+
[normalize(grant.path), grant.type, grant.ugid, grant.roleid].join(' ');
|
|
48
|
+
|
|
49
|
+
const find = (rows: readonly access.ListAccessAclResponseBodyItem[], props: AclProps) =>
|
|
50
|
+
rows.find(
|
|
51
|
+
(row) =>
|
|
52
|
+
row.path === normalize(props.path) &&
|
|
53
|
+
row.type === props.type &&
|
|
54
|
+
row.ugid === props.ugid &&
|
|
55
|
+
row.roleid === props.roleid,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ PVE answers `propagate` as 1/0 rather than true/false, and OMITS it when it carries the API
|
|
60
|
+
* default, which is ON. distilled's generated schema types it `unknown` for the same reason.
|
|
61
|
+
*/
|
|
62
|
+
const propagates = (row: access.ListAccessAclResponseBodyItem) => {
|
|
63
|
+
const value = row.propagate;
|
|
64
|
+
return value === undefined || value === 1 || value === true || value === '1';
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const attributesOf = (
|
|
68
|
+
rows: readonly access.ListAccessAclResponseBodyItem[],
|
|
69
|
+
props: AclProps,
|
|
70
|
+
): AclAttributes => {
|
|
71
|
+
const row = find(rows, props);
|
|
72
|
+
return {
|
|
73
|
+
bound: row !== undefined,
|
|
74
|
+
path: normalize(props.path),
|
|
75
|
+
propagate: row !== undefined && propagates(row),
|
|
76
|
+
roleid: props.roleid,
|
|
77
|
+
type: props.type,
|
|
78
|
+
ugid: props.ugid,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const matches = (attributes: AclAttributes, props: AclProps) =>
|
|
83
|
+
attributes.bound && attributes.propagate === (props.propagate !== false);
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -47,9 +47,15 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
47
47
|
import * as Provider from 'alchemy/Provider';
|
|
48
48
|
import * as access from '@distilled.cloud/proxmox/access';
|
|
49
49
|
import * as Effect from 'effect/Effect';
|
|
50
|
-
import {
|
|
50
|
+
import { attributesOf, bind, guardWrite, identity, matches, tuple } from './acl-wire.ts';
|
|
51
51
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
52
52
|
import { runPve } from './distilled-pve.ts';
|
|
53
|
+
import {
|
|
54
|
+
UNREADABLE,
|
|
55
|
+
type Unreadable,
|
|
56
|
+
readOrUnreadable,
|
|
57
|
+
unreadableWarning,
|
|
58
|
+
} from './unreadable-read.ts';
|
|
53
59
|
|
|
54
60
|
/** PVE's three kinds of subject. The read answers this word; the write wants its plural. */
|
|
55
61
|
export type AclSubjectType = 'user' | 'group' | 'token';
|
|
@@ -91,91 +97,29 @@ export interface ProxmoxAcl extends Resource<
|
|
|
91
97
|
|
|
92
98
|
export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
|
|
93
99
|
|
|
94
|
-
/** ⚠️ PVE normalises ACL paths (collapses repeated slashes, strips the trailing one, adds a leading one). */
|
|
95
|
-
const normalize = (raw: string) => `/${raw.split('/').filter(Boolean).join('/')}`;
|
|
96
|
-
|
|
97
|
-
/** The write names the subject with a plural key that differs per kind; the read answers singular. */
|
|
98
|
-
const SUBJECT_FIELD = { group: 'groups', token: 'tokens', user: 'users' } as const;
|
|
99
|
-
|
|
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 => ({
|
|
105
|
-
path: normalize(props.path),
|
|
106
|
-
roles: props.roleid,
|
|
107
|
-
[SUBJECT_FIELD[props.type]]: props.ugid,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
/** Create and update are one call: the tuple plus the single mutable field. */
|
|
111
|
-
const bind = (props: AclProps): access.PutAccessAclRequest => ({
|
|
112
|
-
...tuple(props),
|
|
113
|
-
propagate: props.propagate === false ? '0' : '1',
|
|
114
|
-
});
|
|
115
|
-
|
|
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));
|
|
119
|
-
|
|
120
|
-
/**
|
|
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.
|
|
125
|
-
*/
|
|
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
|
-
);
|
|
140
|
-
|
|
141
|
-
/**
|
|
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.
|
|
144
|
-
*/
|
|
145
|
-
const propagates = (row: access.ListAccessAclResponseBodyItem) => {
|
|
146
|
-
const value = row.propagate;
|
|
147
|
-
return value === undefined || value === 1 || value === true || value === '1';
|
|
148
|
-
};
|
|
149
|
-
|
|
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
100
|
/**
|
|
169
101
|
* ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
|
|
170
|
-
* ⛔ on filtering.
|
|
171
|
-
*
|
|
102
|
+
* ⛔ on filtering.
|
|
103
|
+
* ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
|
|
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'`).
|
|
172
113
|
*/
|
|
173
114
|
const readAttributes = (props: AclProps) =>
|
|
174
|
-
runPve(props.target, 'provision', false, access.listAccessAcl({})).pipe(
|
|
175
|
-
Effect.map((rows) => attributesOf(rows, props)),
|
|
176
|
-
Effect.orElseSucceed(() => undefined),
|
|
115
|
+
readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
|
|
116
|
+
Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
|
|
177
117
|
);
|
|
178
118
|
|
|
119
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
120
|
+
const dropUnreadable = (live: AclAttributes | Unreadable | undefined) =>
|
|
121
|
+
live === UNREADABLE ? undefined : live;
|
|
122
|
+
|
|
179
123
|
export const ProxmoxAclProvider = () =>
|
|
180
124
|
Provider.effect(
|
|
181
125
|
ProxmoxAcl,
|
|
@@ -184,7 +128,7 @@ export const ProxmoxAclProvider = () =>
|
|
|
184
128
|
/** ⛔ EMPTY LIKE EVERY OTHER PVE RESOURCE. Adopting every grant a human ever clicked is not adoption. */
|
|
185
129
|
list: () => Effect.succeed([]),
|
|
186
130
|
read: Effect.fn(function* ({ olds }) {
|
|
187
|
-
return yield* readAttributes(olds);
|
|
131
|
+
return dropUnreadable(yield* readAttributes(olds));
|
|
188
132
|
}),
|
|
189
133
|
/**
|
|
190
134
|
* ⛔ AN IDENTITY CHANGE IS A REPLACE. Editing `roleid` (or the subject, or the path) would
|
|
@@ -201,6 +145,12 @@ export const ProxmoxAclProvider = () =>
|
|
|
201
145
|
yield* guardWrite(news);
|
|
202
146
|
if (output === undefined) return undefined;
|
|
203
147
|
const live = yield* readAttributes(news);
|
|
148
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and
|
|
149
|
+
// force `update` on a grant that was plainly there — see unreadable-read.ts.
|
|
150
|
+
if (live === UNREADABLE) {
|
|
151
|
+
yield* unreadableWarning('Proxmox.Acl', identity(news));
|
|
152
|
+
return { action: 'noop' } as const;
|
|
153
|
+
}
|
|
204
154
|
return live !== undefined && matches(live, news)
|
|
205
155
|
? ({ action: 'noop' } as const)
|
|
206
156
|
: ({ action: 'update' } as const);
|
|
@@ -212,12 +162,15 @@ export const ProxmoxAclProvider = () =>
|
|
|
212
162
|
* the gap.
|
|
213
163
|
*/
|
|
214
164
|
reconcile: Effect.fn(function* ({ news }) {
|
|
215
|
-
|
|
165
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
166
|
+
// write below, so `UNREADABLE` here is a near-impossible race, not the routine case
|
|
167
|
+
// `diff` handles — treating it as "not yet bound" costs at most one redundant PUT.
|
|
168
|
+
const before = dropUnreadable(yield* readAttributes(news));
|
|
216
169
|
yield* guardWrite(news);
|
|
217
170
|
if (before === undefined || !matches(before, news)) {
|
|
218
171
|
yield* runPve(news.target, 'provision', true, access.putAccessAcl(bind(news)));
|
|
219
172
|
}
|
|
220
|
-
const after = yield* readAttributes(news);
|
|
173
|
+
const after = dropUnreadable(yield* readAttributes(news));
|
|
221
174
|
if (after === undefined || !after.bound) {
|
|
222
175
|
return yield* Effect.die(
|
|
223
176
|
new Error(
|
|
@@ -16,9 +16,11 @@ import { engineOver } from '../verify/fake-engine.ts';
|
|
|
16
16
|
import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
17
17
|
import type { PbsTarget } from './credentials.ts';
|
|
18
18
|
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
19
20
|
import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
20
21
|
import { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
|
|
21
22
|
import { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
|
|
23
|
+
import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
22
24
|
|
|
23
25
|
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
24
26
|
|
|
@@ -54,6 +56,22 @@ const cases: readonly Case[] = [
|
|
|
54
56
|
name: 'Proxmox.Acl (wraps the factory reconcile)',
|
|
55
57
|
provider: ProxmoxAclProvider as never,
|
|
56
58
|
},
|
|
59
|
+
{
|
|
60
|
+
declare: () =>
|
|
61
|
+
ProxmoxGroup('mint', { comment: 'fence', groupid: 'hf-mint', target: FAKE_TARGET }) as never,
|
|
62
|
+
live: { 'access/groups/hf-mint': { comment: 'fence', members: [] } },
|
|
63
|
+
name: 'Proxmox.Group (its own reconcile, since the distilled migration)',
|
|
64
|
+
provider: ProxmoxGroupProvider as never,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// ⚠️ KEY IS PERCENT-ENCODED — user.ts's own ⚠️: distilled's `{userid}` substitution encodes
|
|
68
|
+
// the `@`, and `clusterWith` below does no decoding of `call.path`.
|
|
69
|
+
declare: () =>
|
|
70
|
+
ProxmoxUser('iac', { comment: 'x', target: FAKE_TARGET, userid: 'iac@pve' }) as never,
|
|
71
|
+
live: { 'access/users/iac%40pve': { comment: 'x', enable: 1, expire: 0 } },
|
|
72
|
+
name: 'Proxmox.User (its own reconcile, since the distilled migration)',
|
|
73
|
+
provider: ProxmoxUserProvider as never,
|
|
74
|
+
},
|
|
57
75
|
{
|
|
58
76
|
declare: () =>
|
|
59
77
|
PbsDatastore('store1', {
|
|
@@ -17,10 +17,6 @@ import { engineOver } from '../verify/fake-engine.ts';
|
|
|
17
17
|
import { type ApiTokenProps, ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
|
|
18
18
|
import { shape } from './api-token-form.ts';
|
|
19
19
|
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
20
|
-
import type {
|
|
21
|
-
AccessUsersUseridTokenTokenidPostParams,
|
|
22
|
-
AccessUsersUseridTokenTokenidPutParams,
|
|
23
|
-
} from './generated/pve.ts';
|
|
24
20
|
|
|
25
21
|
type Live = { comment?: string; expire: number | string; privsep: number };
|
|
26
22
|
|
|
@@ -32,12 +28,19 @@ const tokens = new Map<string, Live>([
|
|
|
32
28
|
['access/users/svc@pve/token/lease2', { comment: 'lease', expire: '1789327175', privsep: 0 }],
|
|
33
29
|
]);
|
|
34
30
|
|
|
31
|
+
/**
|
|
32
|
+
* ⚠️ `decodeURIComponent` — user.ts's own ⚠️, and it applies here too: distilled's `{userid}`/
|
|
33
|
+
* `{tokenid}` label substitution percent-encodes `iac@pve` to `iac%40pve`, where `client.ts`'s
|
|
34
|
+
* plain string concatenation never did. `tokens`'s keys stay literal (`@`); only the lookup
|
|
35
|
+
* decodes, so this map does not have to be rewritten for a wire-level detail.
|
|
36
|
+
*/
|
|
35
37
|
const cluster = () =>
|
|
36
38
|
fakePve((call: PveCall) => {
|
|
37
|
-
|
|
39
|
+
const key = decodeURIComponent(call.path);
|
|
40
|
+
if (call.method === 'GET') return tokens.get(key);
|
|
38
41
|
if (call.method === 'PUT') {
|
|
39
|
-
const live = tokens.get(
|
|
40
|
-
if (live !== undefined) tokens.set(
|
|
42
|
+
const live = tokens.get(key);
|
|
43
|
+
if (live !== undefined) tokens.set(key, { ...live, ...call.form });
|
|
41
44
|
return call.form;
|
|
42
45
|
}
|
|
43
46
|
return undefined;
|
|
@@ -125,14 +128,27 @@ describe('adopting a metadata-only ApiToken declaration', () => {
|
|
|
125
128
|
expect(await engine.deploy(flipped())).toEqual({ 'iac-pve-apply': 'adopted' });
|
|
126
129
|
});
|
|
127
130
|
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
128
|
-
|
|
131
|
+
// ⚠️ PERCENT-ENCODED — see the ⚠️ on `cluster` above.
|
|
132
|
+
expect(fake.writes()).toEqual(['PUT access/users/iac%40pve/token/apply']);
|
|
133
|
+
// ⛔ `userid`/`tokenid` DO NOT LEAK INTO THE WIRE FORM, even though `shape()` (below) must
|
|
134
|
+
// include them for distilled's `putAccessUserToken` call — they are `T.Label()` fields,
|
|
135
|
+
// pulled into the URL by the protocol layer and never sent in the body. MEASURED here.
|
|
129
136
|
expect(put?.pairs.map(([name]) => name)).toEqual(['comment', 'expire', 'privsep']);
|
|
130
137
|
});
|
|
131
138
|
|
|
132
|
-
|
|
139
|
+
// ★ THE OLD PROOF ("typechecks against the generated PVE params, no cast") is now `shape()`'s
|
|
140
|
+
// own return type (`access.PutAccessUserTokenRequest`), checked at compile time by every
|
|
141
|
+
// caller. This pins the one thing that is not: `userid`/`tokenid` ARE in the object `shape`
|
|
142
|
+
// builds (distilled needs them to fill the URL), even though the wire test above proves
|
|
143
|
+
// they never reach the actual PUT body.
|
|
144
|
+
test('shape() carries userid/tokenid for distilled, alongside the three mutable fields', () => {
|
|
133
145
|
const props = propsFor('iac@pve', 'apply', { comment: 'automation' });
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
expect(shape(props)).toEqual({
|
|
147
|
+
comment: 'automation',
|
|
148
|
+
expire: '0',
|
|
149
|
+
privsep: '0',
|
|
150
|
+
tokenid: 'apply',
|
|
151
|
+
userid: 'iac@pve',
|
|
152
|
+
});
|
|
137
153
|
});
|
|
138
154
|
});
|