@homeflare/alchemy 0.37.1 → 0.37.3

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 (195) hide show
  1. package/dist/proxmox/backup-job-config.d.ts +95 -0
  2. package/dist/proxmox/backup-job-config.d.ts.map +1 -0
  3. package/dist/proxmox/backup-job-distilled.d.ts +46 -0
  4. package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
  5. package/dist/proxmox/backup-job.d.ts +9 -26
  6. package/dist/proxmox/backup-job.d.ts.map +1 -1
  7. package/dist/proxmox/ceph-fs-distilled.d.ts +17 -10
  8. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
  9. package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
  10. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-fs.d.ts +3 -3
  12. package/dist/proxmox/distilled-pbs.d.ts +12 -0
  13. package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
  14. package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
  15. package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
  16. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
  17. package/dist/proxmox/fake-pve.d.ts +1 -1
  18. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  19. package/dist/proxmox/group-wire.d.ts +2 -2
  20. package/dist/proxmox/group-wire.d.ts.map +1 -1
  21. package/dist/proxmox/index.js +1382 -506
  22. package/dist/proxmox/index.js.map +51 -27
  23. package/dist/proxmox/live-core-fixtures.d.ts +40 -0
  24. package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
  25. package/dist/proxmox/metric-server-config.d.ts +49 -0
  26. package/dist/proxmox/metric-server-config.d.ts.map +1 -0
  27. package/dist/proxmox/metric-server-distilled.d.ts +43 -0
  28. package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
  29. package/dist/proxmox/metric-server.d.ts +2 -10
  30. package/dist/proxmox/metric-server.d.ts.map +1 -1
  31. package/dist/proxmox/node-network-wire.d.ts +1 -1
  32. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  33. package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
  34. package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
  35. package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
  36. package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
  37. package/dist/proxmox/notification-matcher.d.ts +5 -5
  38. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  39. package/dist/proxmox/notification-sdk-input.d.ts +22 -0
  40. package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
  41. package/dist/proxmox/notification-target-distilled.d.ts +12 -0
  42. package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
  43. package/dist/proxmox/notification-target-form.d.ts +2 -0
  44. package/dist/proxmox/notification-target-form.d.ts.map +1 -1
  45. package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
  46. package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
  47. package/dist/proxmox/notification-target-spec.d.ts +5 -0
  48. package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
  49. package/dist/proxmox/notification-target.d.ts +4 -5
  50. package/dist/proxmox/notification-target.d.ts.map +1 -1
  51. package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
  52. package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
  53. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
  54. package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
  55. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
  56. package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
  57. package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
  58. package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
  59. package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
  60. package/dist/proxmox/pbs-datastore.d.ts +5 -6
  61. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  62. package/dist/proxmox/pbs-notification-input.d.ts +18 -0
  63. package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
  64. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
  65. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
  66. package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
  67. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  68. package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
  69. package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
  70. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
  71. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
  72. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  73. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
  74. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
  75. package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
  76. package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
  77. package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
  78. package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
  79. package/dist/proxmox/pbs-prune-job.d.ts +9 -8
  80. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  81. package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
  82. package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
  83. package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
  84. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  85. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
  86. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
  87. package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
  88. package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
  89. package/dist/proxmox/pbs-sync-job.d.ts +9 -39
  90. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
  91. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
  92. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
  93. package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
  94. package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
  95. package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
  96. package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
  97. package/dist/proxmox/pbs-verify-job.d.ts +12 -12
  98. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  99. package/dist/proxmox/pool.d.ts +10 -1
  100. package/dist/proxmox/pool.d.ts.map +1 -1
  101. package/dist/proxmox/storage-wire.d.ts +2 -2
  102. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  103. package/dist/proxmox/user-wire.d.ts +2 -2
  104. package/dist/proxmox/user-wire.d.ts.map +1 -1
  105. package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
  106. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
  107. package/docs/changelog/README.md +22 -0
  108. package/docs/changelog/archive-01.md +176 -0
  109. package/docs/changelog/archive-02.md +180 -0
  110. package/docs/changelog/archive-03.md +179 -0
  111. package/docs/changelog/archive-04.md +178 -0
  112. package/docs/changelog/archive-05.md +177 -0
  113. package/docs/changelog/archive-06.md +180 -0
  114. package/docs/changelog/archive-07.md +180 -0
  115. package/docs/changelog/archive-08.md +178 -0
  116. package/docs/changelog/archive-09.md +181 -0
  117. package/docs/changelog/archive-10.md +182 -0
  118. package/docs/changelog/archive-11.md +182 -0
  119. package/docs/changelog/archive-12.md +183 -0
  120. package/docs/changelog/archive-13.md +180 -0
  121. package/docs/changelog/archive-14.md +178 -0
  122. package/docs/changelog/archive-15.md +181 -0
  123. package/docs/changelog/archive-16.md +182 -0
  124. package/docs/changelog/archive-17.md +183 -0
  125. package/docs/changelog/archive-18.md +55 -0
  126. package/docs/pbs-distilled-jobs.md +31 -0
  127. package/docs/pbs-notifications.md +8 -2
  128. package/docs/proxmox-notification-sdk.md +28 -0
  129. package/docs/proxmox-sdk-migration.md +60 -0
  130. package/docs/proxmox.md +9 -3
  131. package/docs/upstream-conformance-families.md +6 -2
  132. package/package.json +3 -3
  133. package/src/proxmox/adopt-noop.test.ts +4 -5
  134. package/src/proxmox/backup-job-config.ts +95 -0
  135. package/src/proxmox/backup-job-distilled.ts +86 -0
  136. package/src/proxmox/backup-job.ts +78 -116
  137. package/src/proxmox/ceph-fs-delete.test.ts +5 -8
  138. package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
  139. package/src/proxmox/ceph-fs-distilled.ts +41 -13
  140. package/src/proxmox/ceph-fs-wire.ts +5 -93
  141. package/src/proxmox/ceph-fs.ts +7 -7
  142. package/src/proxmox/constraints.test.ts +9 -8
  143. package/src/proxmox/distilled-pbs.test.ts +87 -0
  144. package/src/proxmox/distilled-pbs.ts +50 -0
  145. package/src/proxmox/fake-pbs-jobs.ts +99 -0
  146. package/src/proxmox/fake-pbs-notify.ts +4 -1
  147. package/src/proxmox/fake-pve.ts +3 -2
  148. package/src/proxmox/live-core-fixtures.ts +126 -0
  149. package/src/proxmox/live-core-lifecycle.test.ts +115 -0
  150. package/src/proxmox/live-core-wire.test.ts +190 -0
  151. package/src/proxmox/metric-server-config.ts +87 -0
  152. package/src/proxmox/metric-server-distilled.ts +81 -0
  153. package/src/proxmox/metric-server.ts +77 -88
  154. package/src/proxmox/notification-distilled.test.ts +162 -0
  155. package/src/proxmox/notification-matcher-distilled.ts +60 -0
  156. package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
  157. package/src/proxmox/notification-matcher.ts +9 -26
  158. package/src/proxmox/notification-replacement.test.ts +190 -0
  159. package/src/proxmox/notification-sdk-input.ts +39 -0
  160. package/src/proxmox/notification-target-distilled.ts +160 -0
  161. package/src/proxmox/notification-target-form.ts +2 -0
  162. package/src/proxmox/notification-target-lifecycle.ts +75 -0
  163. package/src/proxmox/notification-target-spec.ts +68 -0
  164. package/src/proxmox/notification-target.ts +6 -68
  165. package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
  166. package/src/proxmox/pbs-datastore-form.ts +18 -49
  167. package/src/proxmox/pbs-datastore-guard.ts +7 -4
  168. package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
  169. package/src/proxmox/pbs-datastore-wire.ts +94 -0
  170. package/src/proxmox/pbs-datastore.ts +7 -133
  171. package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
  172. package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
  173. package/src/proxmox/pbs-notification-errors.test.ts +93 -0
  174. package/src/proxmox/pbs-notification-input.ts +25 -0
  175. package/src/proxmox/pbs-notification-mail.test.ts +64 -0
  176. package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
  177. package/src/proxmox/pbs-notification-matcher.ts +70 -17
  178. package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
  179. package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
  180. package/src/proxmox/pbs-notification-target.ts +1 -1
  181. package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
  182. package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
  183. package/src/proxmox/pbs-prune-job-spec.ts +53 -0
  184. package/src/proxmox/pbs-prune-job-wire.ts +68 -0
  185. package/src/proxmox/pbs-prune-job.ts +10 -58
  186. package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
  187. package/src/proxmox/pbs-sync-job-form.ts +7 -6
  188. package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
  189. package/src/proxmox/pbs-sync-job-wire.ts +71 -0
  190. package/src/proxmox/pbs-sync-job.ts +10 -41
  191. package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
  192. package/src/proxmox/pbs-verify-job-spec.ts +76 -0
  193. package/src/proxmox/pbs-verify-job-wire.ts +58 -0
  194. package/src/proxmox/pbs-verify-job.ts +13 -85
  195. package/src/proxmox/pool.ts +92 -10
@@ -28,9 +28,9 @@
28
28
  *
29
29
  * ★ MIGRATED OFF `client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s typed
30
30
  * `nodes.listNodeCephFs`/`updateNodeCephFs` for the READ and the CREATE (2026-09-24, decision
31
- * 43's walk-down, 2c — ceph-fs-distilled.ts). `destroyFs` STAYS on `client.ts`, unmigrated:
32
- * the SDK originally put `remove-pools`/`remove-storages` in a DELETE body, which PVE ignores.
33
- * SDK PR #265 fixes that protocol gap; migrating `destroyFs` remains a separate change.
31
+ * 43's walk-down, 2c — ceph-fs-distilled.ts). DELETE originally stayed on `client.ts`:
32
+ * the SDK put its flags in a body that PVE discards (ceph-fs-wire.ts's history).
33
+ * SDK 0.3.0 fixes the query binding; delete now uses it and catches only CephFsNotFound.
34
34
  * The typed-read follow-up removes the catch-all fold from `readFs`: a failed index must
35
35
  * fail read/diff/reconcile/delete, not report a missing filesystem or a successful destroy.
36
36
  *
@@ -67,8 +67,8 @@ import { Resource } from 'alchemy';
67
67
  import { isResolved } from 'alchemy/Diff';
68
68
  import * as Provider from 'alchemy/Provider';
69
69
  import * as Effect from 'effect/Effect';
70
- import { createFs, readFs } from './ceph-fs-distilled.ts';
71
- import { CEPH_FS_CREATE, createForm, destroyFs, notCreated, notDestroyed } from './ceph-fs-wire.ts';
70
+ import { createFs, destroyFs, readFs } from './ceph-fs-distilled.ts';
71
+ import { CEPH_FS_CREATE, createForm, notCreated, notDestroyed } from './ceph-fs-wire.ts';
72
72
  import { guardWrite } from './distilled-guard.ts';
73
73
  import { type PveRequirements, type WithTarget } from './resource.ts';
74
74
 
@@ -222,8 +222,8 @@ export const ProxmoxCephFsProvider = () =>
222
222
  * ⛔ THE READ-BACK IS WHAT TURNS "the task said OK" INTO "it is actually gone": `destroyfs`
223
223
  * refuses while a non-disabled `cephfs` storage still references the filesystem, and that
224
224
  * refusal must fail the destroy rather than letting Alchemy drop the state entry for a
225
- * live object. `destroyFs` itself stays on `client.ts` — ceph-fs-wire.ts's own header has
226
- * the measured, protocol-level reason.
225
+ * live object. `destroyFs` now uses the SDK query binding — ceph-fs-wire.ts preserves
226
+ * the measured DELETE query-binding history.
227
227
  */
228
228
  delete: Effect.fn(function* ({ olds }) {
229
229
  /**
@@ -18,7 +18,8 @@ import { engineOver } from '../verify/fake-engine.ts';
18
18
  import { constraintsFor, formViolations } from './constraint-guard.ts';
19
19
  import { type EndpointConstraints, refusal, violations } from './constraints.ts';
20
20
  import type { PbsTarget } from './credentials.ts';
21
- import { fakePve, withoutBao } from './fake-pve.ts';
21
+ import { withoutBao } from './fake-pve.ts';
22
+ import { fakePbsJobs } from './fake-pbs-jobs.ts';
22
23
  import { PROXMOX_CONSTRAINTS, PROXMOX_CONSTRAINTS_DIGEST } from './generated/constraints/index.ts';
23
24
  import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
24
25
 
@@ -46,12 +47,8 @@ describe('the v-r2-offsite failure, refused at plan instead of by the server', (
46
47
  * skip entirely (`output === undefined` returned before any check). It remembers the POST so
47
48
  * the read-back in `reconcile` can succeed, which is what the 128-character case needs.
48
49
  */
49
- const live = new Map<string, Record<string, unknown>>();
50
- const fake = fakePve((call) => {
51
- if (call.method === 'GET') return live.get(call.path);
52
- if (call.method === 'POST') live.set(`config/verify/${String(call.form['id'])}`, call.form);
53
- return undefined;
54
- });
50
+ // Missing PBS sections are measured plain-text HTTP400, not HTTP200 data:null.
51
+ const fake = fakePbsJobs();
55
52
  const engine = engineOver(PbsVerifyJobProvider().pipe(Layer.provideMerge(fake.layer)));
56
53
  const outcome = await withoutBao(() =>
57
54
  engine.deploy(job(comment)).then(
@@ -59,7 +56,11 @@ describe('the v-r2-offsite failure, refused at plan instead of by the server', (
59
56
  (error: unknown) => ({ error }),
60
57
  ),
61
58
  );
62
- return { ...outcome, calls: fake.calls, writes: fake.writes() };
59
+ return {
60
+ ...outcome,
61
+ calls: fake.calls,
62
+ writes: fake.writes.map((row) => `${row.method} ${row.path}`),
63
+ };
63
64
  };
64
65
 
65
66
  test('129 characters is refused, and NOTHING is written', async () => {
@@ -0,0 +1,87 @@
1
+ /** The real PBS SDK protocol: correct credentials, bounded attempts, and no implicit re-send. */
2
+ import { expect, test } from 'bun:test';
3
+ import * as config from '@distilled.cloud/proxmox-backup/config';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Layer from 'effect/Layer';
6
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
7
+ import type { PbsTarget, PveCredential } from './credentials.ts';
8
+ import { runPbsWith } from './distilled-pbs.ts';
9
+
10
+ const TARGET: PbsTarget = { api: 'https://pbs.invalid:8007', mount: 'pbs-test', scheme: 'pbs' };
11
+ const CREDENTIAL: PveCredential = {
12
+ leaseSeconds: 300,
13
+ secret: 'fake-secret-not-real',
14
+ tokenId: 'test@pbs!fake',
15
+ };
16
+
17
+ const transport = (answer: (request: Request) => Response | Promise<Response>) => {
18
+ const stub = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
19
+ Promise.resolve(
20
+ answer(
21
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init),
22
+ ),
23
+ );
24
+ return FetchHttpClient.layer.pipe(
25
+ Layer.provideMerge(
26
+ Layer.succeed(
27
+ FetchHttpClient.Fetch,
28
+ Object.assign(stub, { preconnect: globalThis.fetch.preconnect }),
29
+ ),
30
+ ),
31
+ );
32
+ };
33
+
34
+ test('the PBS SDK builds its own colon-separated authorization and normalizes the API root', async () => {
35
+ let called = false;
36
+ const layer = transport((request) => {
37
+ called = true;
38
+ expect(request.url).toBe(
39
+ 'https://pbs.invalid:8007/api2/json/config/notifications/matchers/pager',
40
+ );
41
+ expect(request.headers.get('authorization')).toBe(
42
+ 'PBSAPIToken=test@pbs!fake:fake-secret-not-real',
43
+ );
44
+ return Response.json({ data: { name: 'pager', target: ['mail-to-root'] } });
45
+ });
46
+ const row = await Effect.runPromise(
47
+ runPbsWith(TARGET, CREDENTIAL, config.getConfigNotificationMatcher({ name: 'pager' })).pipe(
48
+ Effect.provide(layer),
49
+ ),
50
+ );
51
+ expect(called).toBe(true);
52
+ expect(row.target).toEqual(['mail-to-root']);
53
+ });
54
+
55
+ test('a typed HTTP failure is returned once, without the SDK default retry', async () => {
56
+ let calls = 0;
57
+ const layer = transport(() => {
58
+ calls += 1;
59
+ return Response.json({ message: 'unavailable' }, { status: 503 });
60
+ });
61
+ const error = await Effect.runPromise(
62
+ runPbsWith(TARGET, CREDENTIAL, config.deleteConfigNotificationMatcher({ name: 'pager' })).pipe(
63
+ Effect.flip,
64
+ Effect.provide(layer),
65
+ ),
66
+ );
67
+ expect(error).toMatchObject({ _tag: 'ServiceUnavailable' });
68
+ expect(calls).toBe(1);
69
+ });
70
+
71
+ test('a sent write that never answers is bounded and never repeated', async () => {
72
+ let calls = 0;
73
+ const layer = transport(() => {
74
+ calls += 1;
75
+ return new Promise<Response>(() => {});
76
+ });
77
+ const error = await Effect.runPromise(
78
+ runPbsWith(
79
+ TARGET,
80
+ CREDENTIAL,
81
+ config.deleteConfigNotificationMatcher({ name: 'pager' }),
82
+ '20 millis',
83
+ ).pipe(Effect.flip, Effect.provide(layer)),
84
+ );
85
+ expect(error).toMatchObject({ _tag: 'TimeoutError' });
86
+ expect(calls).toBe(1);
87
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PBS 4.2.6-1 operations through the distilled SDK, with the existing OpenBao leases.
3
+ *
4
+ * ⛔ PBS IS A SINGLE HOST, NOT A PVE CLUSTER. Its Credentials layer supplies the
5
+ * `PBSAPIToken=id:secret` scheme; running a PBS operation through runPve would send PVE's
6
+ * different header. Nothing here invents another host or retries a possibly completed write.
7
+ * ★ Retry.none also disables distilled's default transient-error retries within that host.
8
+ * MEMBER_TIMEOUT bounds the one attempt, just as it bounds each PVE member attempt.
9
+ */
10
+ import { credentials } from '@distilled.cloud/proxmox-backup/Credentials';
11
+ import type { ProxmoxBackupOpContext } from '@distilled.cloud/proxmox-backup/Protocol';
12
+ import * as Retry from '@distilled.cloud/proxmox-backup/Retry';
13
+ import type * as Cause from 'effect/Cause';
14
+ import type * as Duration from 'effect/Duration';
15
+ import * as Effect from 'effect/Effect';
16
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
17
+ import type { PveCredentialDenied } from './credential-errors.ts';
18
+ import type { PbsTarget, PveCredential, PveRole } from './credentials.ts';
19
+ import { leased } from './lease-cache.ts';
20
+ import { MEMBER_TIMEOUT } from './members.ts';
21
+
22
+ /** An already-held lease, also used by offline protocol tests. Production uses the default bound. */
23
+ export const runPbsWith = <A, E>(
24
+ target: PbsTarget,
25
+ credential: PveCredential,
26
+ op: Effect.Effect<A, E, ProxmoxBackupOpContext>,
27
+ timeout: Duration.Input = MEMBER_TIMEOUT,
28
+ ): Effect.Effect<A, E | Cause.TimeoutError, HttpClient.HttpClient> =>
29
+ op.pipe(
30
+ Retry.none,
31
+ Effect.provide(
32
+ credentials({
33
+ baseUrl: target.api,
34
+ secret: credential.secret,
35
+ tokenId: credential.tokenId,
36
+ }),
37
+ ),
38
+ Effect.timeout(timeout),
39
+ );
40
+
41
+ /** Preserve lease sharing and expose credential refusal and SDK error tags unchanged. */
42
+ export const runPbs = <A, E>(
43
+ target: PbsTarget,
44
+ role: PveRole,
45
+ op: Effect.Effect<A, E, ProxmoxBackupOpContext>,
46
+ ): Effect.Effect<A, E | Cause.TimeoutError | PveCredentialDenied | Error, HttpClient.HttpClient> =>
47
+ Effect.gen(function* () {
48
+ const credential = yield* leased(target, role);
49
+ return yield* runPbsWith(target, credential, op);
50
+ });
@@ -0,0 +1,99 @@
1
+ /** Test-only PBS 4.2.6-1 wire fixture. Every HTTP request is intercepted; no live credentials. */
2
+ import * as Layer from 'effect/Layer';
3
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
4
+ import type { PbsTarget } from './credentials.ts';
5
+
6
+ export const PBS_JOBS_TARGET: PbsTarget = {
7
+ api: 'https://pbs.test:8007/api2/json',
8
+ mount: 'pbs-test',
9
+ scheme: 'pbs',
10
+ };
11
+
12
+ const sections: Record<string, string> = {
13
+ datastore: 'datastore',
14
+ prune: 'prune',
15
+ sync: 'sync',
16
+ verify: 'verification',
17
+ };
18
+ const numbers =
19
+ /^(keep-(daily|hourly|last|monthly|weekly|yearly)|max-depth|outdated-after|transfer-last)$/;
20
+ const booleans = /^(disable|ignore-verified|remove-vanished|verified-only|verify-new)$/;
21
+
22
+ type Write = { method: string; path: string; form: Record<string, string> };
23
+
24
+ export const fakePbsJobs = () => {
25
+ const rows = new Map<string, Record<string, unknown>>();
26
+ const writes: Write[] = [];
27
+ const calls: { method: string; path: string }[] = [];
28
+ let failure: { status: number; body: unknown } | undefined;
29
+ let deleteFailure: { status: number; body: unknown } | undefined;
30
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
31
+ const request =
32
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
33
+ const url = new URL(request.url);
34
+ if (url.pathname.includes('/creds/')) {
35
+ return Response.json({
36
+ data: { secret: 'fake-secret-not-real', token_id: 'test@pbs!fake' },
37
+ lease_duration: 0,
38
+ });
39
+ }
40
+ const path = decodeURIComponent(url.pathname.replace('/api2/json/', ''));
41
+ calls.push({ method: request.method, path });
42
+ if (request.method === 'GET' && failure !== undefined) {
43
+ return Response.json(failure.body, { status: failure.status });
44
+ }
45
+ const parts = path.split('/');
46
+ const family = parts[1] ?? '';
47
+ const id = parts[2] ?? '';
48
+ const row = rows.get(path);
49
+ if (request.method === 'GET') {
50
+ // ★ Measured live 2026-09-24: these four missing sections are plain-text HTTP400.
51
+ return row === undefined
52
+ ? new Response(`no such ${sections[family]} '${id}'`, { status: 400 })
53
+ : Response.json({ data: row });
54
+ }
55
+ const form = Object.fromEntries(new URLSearchParams(await request.text()));
56
+ writes.push({ method: request.method, path, form });
57
+ if (request.method === 'DELETE') {
58
+ if (deleteFailure !== undefined) {
59
+ return Response.json(deleteFailure.body, { status: deleteFailure.status });
60
+ }
61
+ rows.delete(path);
62
+ return Response.json({ data: family === 'datastore' ? 'UPID:fake:delete' : null });
63
+ }
64
+ const key = request.method === 'POST' ? `${path}/${form['id'] ?? form['name']}` : path;
65
+ const after = { ...rows.get(key) };
66
+ for (const [name, value] of Object.entries(form)) {
67
+ after[name] = numbers.test(name)
68
+ ? Number(value)
69
+ : booleans.test(name)
70
+ ? value === '1'
71
+ : value;
72
+ }
73
+ rows.set(key, after);
74
+ // ★ Pinned schema: create datastore is Unit/null; DELETE alone yields a UPID.
75
+ return Response.json({ data: null });
76
+ };
77
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
78
+ return {
79
+ rows,
80
+ writes,
81
+ calls,
82
+ failRead: (status: number, body: unknown) => {
83
+ failure = { status, body };
84
+ },
85
+ failDelete: (status: number, body: unknown) => {
86
+ deleteFailure = { status, body };
87
+ },
88
+ reset: () => {
89
+ rows.clear();
90
+ calls.length = 0;
91
+ writes.length = 0;
92
+ failure = undefined;
93
+ deleteFailure = undefined;
94
+ },
95
+ layer: FetchHttpClient.layer.pipe(
96
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
97
+ ),
98
+ };
99
+ };
@@ -83,7 +83,10 @@ export const fakeNotify = (
83
83
  const { deletes, fields } = body(call);
84
84
  if (call.method === 'GET') {
85
85
  const object = objects.get(path);
86
- return object === undefined ? undefined : withSecretsBlanked(path, object, secrets);
86
+ // SDK reads require the resource shape: data:null is malformed, not evidence of absence.
87
+ return object === undefined
88
+ ? Response.json({ message: 'not found' }, { status: 404 })
89
+ : withSecretsBlanked(path, object, secrets);
87
90
  }
88
91
  if (call.method === 'DELETE') {
89
92
  objects.delete(path);
@@ -35,7 +35,7 @@ export interface PveCall {
35
35
  readonly pairs: readonly (readonly [string, string])[];
36
36
  }
37
37
 
38
- /** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
38
+ /** Answers with `data`, or an explicit Response for SDK error fixtures; undefined is data:null. */
39
39
  export type PveAnswer = (call: PveCall) => unknown;
40
40
 
41
41
  export interface FakePve {
@@ -69,7 +69,8 @@ export const fakePve = (answer: PveAnswer): FakePve => {
69
69
  path,
70
70
  };
71
71
  calls.push(call);
72
- return Response.json({ data: answer(call) ?? null });
72
+ const value = answer(call);
73
+ return value instanceof Response ? value : Response.json({ data: value ?? null });
73
74
  };
74
75
  // ⚠️ `typeof fetch` carries `preconnect` on bun's lib — client.test.ts has the note.
75
76
  const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
@@ -0,0 +1,126 @@
1
+ /** Test-only declarations and vendor-shaped responses for the three migrated PVE families. */
2
+ import type * as Effect from 'effect/Effect';
3
+ import * as Layer from 'effect/Layer';
4
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
5
+ import { type FakeEngine, engineOver } from '../verify/fake-engine.ts';
6
+ import { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
7
+ import { deleteBackupJob, readBackupJob } from './backup-job-distilled.ts';
8
+ import { FAKE_TARGET, type FakePve } from './fake-pve.ts';
9
+ import { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
10
+ import { deleteMetricServer, readMetricServer } from './metric-server-distilled.ts';
11
+ import { ProxmoxPool, ProxmoxPoolProvider, deletePool, readPool } from './pool.ts';
12
+
13
+ export const poolProps = { poolid: 'lab', comment: 'managed', target: FAKE_TARGET };
14
+ export const backupProps = {
15
+ id: 'nightly',
16
+ schedule: '02:00',
17
+ vmid: [103, 101],
18
+ 'prune-backups': 'keep-weekly=4,keep-daily=7',
19
+ target: FAKE_TARGET,
20
+ };
21
+ export const metricProps = {
22
+ id: 'metrics',
23
+ type: 'influxdb' as const,
24
+ server: 'metrics.test',
25
+ port: 8086,
26
+ target: FAKE_TARGET,
27
+ };
28
+
29
+ export interface CoreCase {
30
+ name: string;
31
+ path: string;
32
+ declare: () => Effect.Effect<unknown, never, unknown>;
33
+ engine: (fake: FakePve) => FakeEngine;
34
+ read: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
35
+ remove: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
36
+ live: Record<string, unknown>;
37
+ drift: Record<string, unknown>;
38
+ missing: () => Response;
39
+ createPath: string;
40
+ /** Actual form fields, including defaults, without SDK identifier spelling leaking onto wire. */
41
+ createForm: Record<string, string>;
42
+ }
43
+ export const coreCases: CoreCase[] = [
44
+ {
45
+ name: 'Pool',
46
+ path: 'pools/lab',
47
+ createPath: 'pools',
48
+ declare: () => ProxmoxPool('row', poolProps),
49
+ engine: (fake) => engineOver(ProxmoxPoolProvider().pipe(Layer.provideMerge(fake.layer))),
50
+ read: () => readPool(poolProps),
51
+ remove: () => deletePool(poolProps),
52
+ live: { comment: 'managed', members: [] },
53
+ drift: { comment: 'old', members: [] },
54
+ missing: () =>
55
+ Response.json({ message: "pool 'lab' does not exist\n", data: null }, { status: 500 }),
56
+ createForm: { poolid: 'lab', comment: 'managed' },
57
+ },
58
+ {
59
+ name: 'BackupJob',
60
+ path: 'cluster/backup/nightly',
61
+ createPath: 'cluster/backup',
62
+ declare: () => ProxmoxBackupJob('row', backupProps),
63
+ engine: (fake) => engineOver(ProxmoxBackupJobProvider().pipe(Layer.provideMerge(fake.layer))),
64
+ read: () => readBackupJob(backupProps),
65
+ remove: () => deleteBackupJob(backupProps),
66
+ live: {
67
+ id: 'nightly',
68
+ schedule: '02:00',
69
+ vmid: '101,103',
70
+ 'next-run': 987654,
71
+ 'prune-backups': { 'keep-daily': 7, 'keep-weekly': 4 },
72
+ compress: 'zstd',
73
+ mailto: 'operator.invalid',
74
+ },
75
+ drift: {
76
+ id: 'nightly',
77
+ schedule: '03:00',
78
+ vmid: '101,103',
79
+ 'prune-backups': { 'keep-daily': 7, 'keep-weekly': 4 },
80
+ },
81
+ missing: () =>
82
+ Response.json(
83
+ {
84
+ message: 'Parameter verification failed.\n',
85
+ errors: { id: "No such job 'nightly'" },
86
+ data: null,
87
+ },
88
+ { status: 400 },
89
+ ),
90
+ createForm: {
91
+ id: 'nightly',
92
+ schedule: '02:00',
93
+ vmid: '101,103',
94
+ all: '0',
95
+ enabled: '1',
96
+ mode: 'snapshot',
97
+ 'notification-mode': 'auto',
98
+ 'repeat-missed': '0',
99
+ 'prune-backups': 'keep-weekly=4,keep-daily=7',
100
+ },
101
+ },
102
+ {
103
+ name: 'MetricServer',
104
+ path: 'cluster/metrics/server/metrics',
105
+ createPath: 'cluster/metrics/server/metrics',
106
+ declare: () => ProxmoxMetricServer('row', metricProps),
107
+ engine: (fake) =>
108
+ engineOver(ProxmoxMetricServerProvider().pipe(Layer.provideMerge(fake.layer))),
109
+ read: () => readMetricServer(metricProps),
110
+ remove: () => deleteMetricServer(metricProps),
111
+ live: { type: 'influxdb', server: 'metrics.test', port: 8086, token: 'fake-not-a-secret' },
112
+ drift: { type: 'influxdb', server: 'old.test', port: 8086, token: 'fake-not-a-secret' },
113
+ missing: () =>
114
+ Response.json(
115
+ { message: "status server entry 'metrics' does not exist\n", data: null },
116
+ { status: 500 },
117
+ ),
118
+ createForm: {
119
+ type: 'influxdb',
120
+ server: 'metrics.test',
121
+ port: '8086',
122
+ disable: '0',
123
+ 'verify-certificate': '1',
124
+ },
125
+ },
126
+ ];
@@ -0,0 +1,115 @@
1
+ /** Real Alchemy Plan/Apply and distilled requests; fixtures never reach a real host. */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
5
+ import { fakePve, withoutBao } from './fake-pve.ts';
6
+ import { coreCases } from './live-core-fixtures.ts';
7
+
8
+ for (const entry of coreCases) {
9
+ describe(`distilled ${entry.name}`, () => {
10
+ test('adoption, subsequent plan and apply have zero writes', async () => {
11
+ const fake = fakePve(() => entry.live);
12
+ await withoutBao(async () => {
13
+ const engine = entry.engine(fake);
14
+ expect((await engine.verify(entry.declare(), { all: true })).rows).toEqual([
15
+ expect.objectContaining({ diff: 'noop', ok: true }),
16
+ ]);
17
+ expect(await engine.deploy(entry.declare())).toEqual({ row: 'adopted' });
18
+ expect((await engine.verify(entry.declare(), { all: true })).rows).toEqual([
19
+ expect.objectContaining({ diff: 'noop', ok: true }),
20
+ ]);
21
+ await engine.deploy(entry.declare());
22
+ expect(engine.stored()).not.toContain('fake-not-a-secret');
23
+ });
24
+ expect(fake.writes()).toEqual([]);
25
+ });
26
+
27
+ test('genuine absence creates once, then reads and converges', async () => {
28
+ let exists = false;
29
+ const fake = fakePve((call) => {
30
+ if (call.method === 'GET') return exists ? entry.live : entry.missing();
31
+ exists = true;
32
+ return null;
33
+ });
34
+ await withoutBao(async () => {
35
+ const engine = entry.engine(fake);
36
+ await engine.deploy(entry.declare());
37
+ expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
38
+ diff: 'noop',
39
+ });
40
+ });
41
+ expect(fake.writes()).toEqual([`POST ${entry.createPath}`]);
42
+ expect(fake.calls.find((call) => call.method === 'POST')?.form).toEqual(entry.createForm);
43
+ });
44
+
45
+ test('live drift produces exactly one PUT and preserves omitted fields', async () => {
46
+ let live = entry.drift;
47
+ const fake = fakePve((call) => {
48
+ if (call.method === 'GET') return live;
49
+ live = entry.live;
50
+ return null;
51
+ });
52
+ await withoutBao(async () => {
53
+ const engine = entry.engine(fake);
54
+ expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
55
+ diff: 'update',
56
+ });
57
+ await engine.deploy(entry.declare());
58
+ expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
59
+ diff: 'noop',
60
+ });
61
+ });
62
+ expect(fake.writes()).toEqual([`PUT ${entry.path}`]);
63
+ const form = fake.calls.find((call) => call.method === 'PUT')?.form;
64
+ expect(form).not.toHaveProperty('token');
65
+ expect(form).not.toHaveProperty('otel-headers');
66
+ expect(form).not.toHaveProperty('compress');
67
+ expect(form).not.toHaveProperty('mailto');
68
+ if (entry.name === 'BackupJob') expect(form).not.toHaveProperty('delete');
69
+ if (entry.name === 'MetricServer') {
70
+ expect(form?.['delete']).toBe(
71
+ 'api-path-prefix,bucket,influxdbproto,max-body-size,mtu,organization,timeout',
72
+ );
73
+ expect(form).not.toHaveProperty('id');
74
+ expect(form).not.toHaveProperty('type');
75
+ }
76
+ });
77
+
78
+ for (const status of [401, 403, 500]) {
79
+ test(`${status} read failure stops cold adoption and confirmed drift before writes`, async () => {
80
+ let fail = true;
81
+ const fake = fakePve(() =>
82
+ fail
83
+ ? Response.json({ data: null, message: 'unrelated failure' }, { status })
84
+ : entry.live,
85
+ );
86
+ await withoutBao(async () => {
87
+ const engine = entry.engine(fake);
88
+ await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
89
+ fail = false;
90
+ await engine.deploy(entry.declare());
91
+ fail = true;
92
+ await expect(engine.drift(entry.declare())).rejects.toBeDefined();
93
+ await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
94
+ });
95
+ expect(fake.writes()).toEqual([]);
96
+ });
97
+ }
98
+
99
+ test('delete failure retains the engine state and propagates', async () => {
100
+ let failing = false;
101
+ const fake = fakePve(() =>
102
+ failing
103
+ ? Response.json({ data: null, message: 'permission denied' }, { status: 403 })
104
+ : entry.live,
105
+ );
106
+ await withoutBao(async () => {
107
+ const engine = entry.engine(fake);
108
+ await engine.deploy(entry.declare().pipe(RemovalPolicy.destroy()));
109
+ failing = true;
110
+ await expect(engine.deploy(Effect.void)).rejects.toBeDefined();
111
+ expect(engine.stored()).toContain('row');
112
+ });
113
+ });
114
+ });
115
+ }