@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.
Files changed (133) hide show
  1. package/README.md +5 -4
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/proxmox/acl-wire.d.ts +26 -0
  5. package/dist/proxmox/acl-wire.d.ts.map +1 -0
  6. package/dist/proxmox/acl.d.ts.map +1 -1
  7. package/dist/proxmox/api-token-form.d.ts +28 -26
  8. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  9. package/dist/proxmox/api-token.d.ts +48 -89
  10. package/dist/proxmox/api-token.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
  12. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  13. package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
  14. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  15. package/dist/proxmox/ceph-osd-write.d.ts +2 -2
  16. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  17. package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
  18. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  19. package/dist/proxmox/client.d.ts +2 -2
  20. package/dist/proxmox/client.d.ts.map +1 -1
  21. package/dist/proxmox/credential-errors.d.ts +27 -0
  22. package/dist/proxmox/credential-errors.d.ts.map +1 -0
  23. package/dist/proxmox/credentials.d.ts +0 -38
  24. package/dist/proxmox/credentials.d.ts.map +1 -1
  25. package/dist/proxmox/distilled-guard.d.ts +31 -0
  26. package/dist/proxmox/distilled-guard.d.ts.map +1 -0
  27. package/dist/proxmox/distilled-pve.d.ts +11 -4
  28. package/dist/proxmox/distilled-pve.d.ts.map +1 -1
  29. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  44. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  45. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  47. package/dist/proxmox/group-wire.d.ts +68 -0
  48. package/dist/proxmox/group-wire.d.ts.map +1 -0
  49. package/dist/proxmox/group.d.ts +20 -24
  50. package/dist/proxmox/group.d.ts.map +1 -1
  51. package/dist/proxmox/index.js +730 -511
  52. package/dist/proxmox/index.js.map +40 -33
  53. package/dist/proxmox/lease-cache.d.ts +1 -1
  54. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  55. package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
  56. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  57. package/dist/proxmox/mint.d.ts +50 -0
  58. package/dist/proxmox/mint.d.ts.map +1 -0
  59. package/dist/proxmox/network-apply-read.d.ts +2 -2
  60. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  61. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
  62. package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
  63. package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
  64. package/dist/proxmox/resource.d.ts +4 -4
  65. package/dist/proxmox/role-wire.d.ts +28 -0
  66. package/dist/proxmox/role-wire.d.ts.map +1 -0
  67. package/dist/proxmox/role.d.ts +36 -52
  68. package/dist/proxmox/role.d.ts.map +1 -1
  69. package/dist/proxmox/sdn-apply-read.d.ts +1 -1
  70. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
  71. package/dist/proxmox/unreadable-read.d.ts +50 -0
  72. package/dist/proxmox/unreadable-read.d.ts.map +1 -0
  73. package/dist/proxmox/user-wire.d.ts +47 -0
  74. package/dist/proxmox/user-wire.d.ts.map +1 -0
  75. package/dist/proxmox/user.d.ts +25 -32
  76. package/dist/proxmox/user.d.ts.map +1 -1
  77. package/dist/proxmox/zfs-pool-write.d.ts +2 -2
  78. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  79. package/dist/verify/fake-engine.d.ts +14 -0
  80. package/dist/verify/fake-engine.d.ts.map +1 -1
  81. package/docs/grafana.md +47 -17
  82. package/docs/upstream-conformance.md +14 -4
  83. package/package.json +2 -2
  84. package/src/grafana/index.ts +6 -5
  85. package/src/proxmox/acl-wire.ts +83 -0
  86. package/src/proxmox/acl.ts +36 -83
  87. package/src/proxmox/adopt-noop.test.ts +18 -0
  88. package/src/proxmox/api-token-adopt.test.ts +28 -12
  89. package/src/proxmox/api-token-form.ts +50 -93
  90. package/src/proxmox/api-token.ts +155 -133
  91. package/src/proxmox/client.ts +2 -7
  92. package/src/proxmox/constraints-forms.test.ts +7 -27
  93. package/src/proxmox/credential-errors.ts +33 -0
  94. package/src/proxmox/credentials.test.ts +29 -3
  95. package/src/proxmox/credentials.ts +4 -134
  96. package/src/proxmox/distilled-guard.ts +42 -0
  97. package/src/proxmox/distilled-pve.ts +15 -4
  98. package/src/proxmox/generated/constraints/index.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-access.ts +1 -4
  100. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/group-wire.ts +117 -0
  116. package/src/proxmox/group.test.ts +110 -0
  117. package/src/proxmox/group.ts +127 -109
  118. package/src/proxmox/lease-cache.ts +2 -1
  119. package/src/proxmox/lxc-lifecycle.ts +2 -1
  120. package/src/proxmox/mint.ts +158 -0
  121. package/src/proxmox/network-apply.ts +1 -1
  122. package/src/proxmox/provision-cli-fake.ts +11 -0
  123. package/src/proxmox/provision-declare.test.ts +6 -1
  124. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  125. package/src/proxmox/role-wire.ts +49 -0
  126. package/src/proxmox/role.test.ts +89 -0
  127. package/src/proxmox/role.ts +152 -104
  128. package/src/proxmox/unreadable-read.test.ts +83 -0
  129. package/src/proxmox/unreadable-read.ts +60 -0
  130. package/src/proxmox/user-wire.ts +148 -0
  131. package/src/proxmox/user.test.ts +142 -0
  132. package/src/proxmox/user.ts +136 -131
  133. 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,UAAU,EAAe,MAAM,eAAe,CAAC;AAGnF,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;AAmGjE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,iIACiD,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"}
@@ -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,0GA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,wFACb,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,UAyC3E,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,UAA+C,CAAC"}
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 has **no create/read/update/delete
13
- operations for folders, dashboards, alert rules or contact points**. It covers datasources in
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` exists. No `createFolder`, `getFolder` or
19
- `deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes are entirely absent.
20
- - **Dashboards:** only snapshot and public-dashboard routes exist (`createDashboardSnapshot`,
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 is absent.
30
+ `DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family was absent.
23
31
  - **Alert rules:** only `routeGetAlertRuleExport`/`routeGetAlertRuleGroupExport`/
24
- `routeGetAlertRulesExport` exist — export (read-only) endpoints under `/api/v1/provisioning/`.
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` exists, same story — no
34
+ - **Contact points:** only `routeGetContactpointsExport` existed, same story — no
27
35
  `/api/v1/provisioning/contact-points{,/{uid}}` CRUD.
28
36
 
29
- This is recorded in [`docs/upstream-conformance.md`](./upstream-conformance.md) rather than worked
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), not a hand-rolled `HttpClient` client in this package for the missing
32
- pieces alone — that would put half a family through the SDK and half through a second, parallel
33
- client, exactly what S23 exists to prevent.
34
-
35
- `teslamate-grafana` itself is consistent with this gap being real, not merely unexploited: its one
36
- datasource is API-manageable and already exists; its dashboards ship baked into the
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.** Blocked on the SDK gap above — a
100
- maintainer decision on whether to patch distilled or wait for upstream to add the routes.
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). **Decision:** maintainer,
253
- on whether the kit patches distilled (S22) or waits for upstream to add the routes — S23
254
- forbids a hand-rolled `HttpClient` client for the missing pieces alone, half a family through
255
- the SDK and half through a second client.
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.0",
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",
@@ -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@1.0.0-rc.12` has no folder,
9
- * dashboard, alert-rule or contact-point CRUD operations — see docs/grafana.md and
10
- * docs/upstream-conformance.md for the measured gap. Adding those resources now would mean
11
- * hand-rolling an `HttpClient` client for them, which S23 forbids for a vendor distilled
12
- * already covers in part; the right fix is upstream, in distilled, not a house workaround here.
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);
@@ -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 { guardForm } from './constraint-guard.ts';
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. `read`/`orElseSucceed` folds ANY failure (network, a genuine permission
171
- * error) into "unknown"; a 200 with an empty or non-matching list is a real answer, not folded.
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
- const before = yield* readAttributes(news);
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
- if (call.method === 'GET') return tokens.get(call.path);
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(call.path);
40
- if (live !== undefined) tokens.set(call.path, { ...live, ...call.form });
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
- expect(fake.writes()).toEqual(['PUT access/users/iac@pve/token/apply']);
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
- test('the form keys typecheck against both generated PVE params, with no cast', () => {
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
- const post: AccessUsersUseridTokenTokenidPostParams = shape(props);
135
- const put: AccessUsersUseridTokenTokenidPutParams = shape(props);
136
- expect(post).toEqual(put);
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
  });