@homeflare/distilled-proxmox-backup 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +102 -0
  3. package/dist/credentials.d.ts +50 -0
  4. package/dist/credentials.d.ts.map +1 -0
  5. package/dist/credentials.js +105 -0
  6. package/dist/credentials.js.map +1 -0
  7. package/dist/errors.d.ts +100 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +73 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +33 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +33 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/protocol.d.ts +19 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +158 -0
  18. package/dist/protocol.js.map +1 -0
  19. package/dist/protocol.test.d.ts +2 -0
  20. package/dist/protocol.test.d.ts.map +1 -0
  21. package/dist/protocol.test.js +75 -0
  22. package/dist/protocol.test.js.map +1 -0
  23. package/dist/retry.d.ts +53 -0
  24. package/dist/retry.d.ts.map +1 -0
  25. package/dist/retry.js +46 -0
  26. package/dist/retry.js.map +1 -0
  27. package/dist/services/access.d.ts +587 -0
  28. package/dist/services/access.d.ts.map +1 -0
  29. package/dist/services/access.js +787 -0
  30. package/dist/services/access.js.map +1 -0
  31. package/dist/services/admin.d.ts +1110 -0
  32. package/dist/services/admin.d.ts.map +1 -0
  33. package/dist/services/admin.js +1428 -0
  34. package/dist/services/admin.js.map +1 -0
  35. package/dist/services/backup.d.ts +224 -0
  36. package/dist/services/backup.d.ts.map +1 -0
  37. package/dist/services/backup.js +325 -0
  38. package/dist/services/backup.js.map +1 -0
  39. package/dist/services/config.d.ts +3662 -0
  40. package/dist/services/config.d.ts.map +1 -0
  41. package/dist/services/config.js +4445 -0
  42. package/dist/services/config.js.map +1 -0
  43. package/dist/services/index.d.ts +14 -0
  44. package/dist/services/index.d.ts.map +1 -0
  45. package/dist/services/index.js +15 -0
  46. package/dist/services/index.js.map +1 -0
  47. package/dist/services/nodes.d.ts +1441 -0
  48. package/dist/services/nodes.d.ts.map +1 -0
  49. package/dist/services/nodes.js +1829 -0
  50. package/dist/services/nodes.js.map +1 -0
  51. package/dist/services/ping.d.ts +15 -0
  52. package/dist/services/ping.d.ts.map +1 -0
  53. package/dist/services/ping.js +21 -0
  54. package/dist/services/ping.js.map +1 -0
  55. package/dist/services/pull.d.ts +54 -0
  56. package/dist/services/pull.d.ts.map +1 -0
  57. package/dist/services/pull.js +47 -0
  58. package/dist/services/pull.js.map +1 -0
  59. package/dist/services/push.d.ts +50 -0
  60. package/dist/services/push.d.ts.map +1 -0
  61. package/dist/services/push.js +45 -0
  62. package/dist/services/push.js.map +1 -0
  63. package/dist/services/reader.d.ts +68 -0
  64. package/dist/services/reader.d.ts.map +1 -0
  65. package/dist/services/reader.js +89 -0
  66. package/dist/services/reader.js.map +1 -0
  67. package/dist/services/root.d.ts +14 -0
  68. package/dist/services/root.d.ts.map +1 -0
  69. package/dist/services/root.js +19 -0
  70. package/dist/services/root.js.map +1 -0
  71. package/dist/services/status.d.ts +78 -0
  72. package/dist/services/status.d.ts.map +1 -0
  73. package/dist/services/status.js +97 -0
  74. package/dist/services/status.js.map +1 -0
  75. package/dist/services/tape.d.ts +711 -0
  76. package/dist/services/tape.d.ts.map +1 -0
  77. package/dist/services/tape.js +986 -0
  78. package/dist/services/tape.js.map +1 -0
  79. package/dist/services/version.d.ts +17 -0
  80. package/dist/services/version.d.ts.map +1 -0
  81. package/dist/services/version.js +25 -0
  82. package/dist/services/version.js.map +1 -0
  83. package/dist/task.d.ts +48 -0
  84. package/dist/task.d.ts.map +1 -0
  85. package/dist/task.js +57 -0
  86. package/dist/task.js.map +1 -0
  87. package/dist/traits.d.ts +13 -0
  88. package/dist/traits.d.ts.map +1 -0
  89. package/dist/traits.js +15 -0
  90. package/dist/traits.js.map +1 -0
  91. package/package.json +81 -0
  92. package/src/credentials.ts +139 -0
  93. package/src/errors.ts +126 -0
  94. package/src/index.ts +36 -0
  95. package/src/protocol.test.ts +128 -0
  96. package/src/protocol.ts +196 -0
  97. package/src/retry.ts +79 -0
  98. package/src/services/access.ts +1787 -0
  99. package/src/services/admin.ts +3258 -0
  100. package/src/services/backup.ts +698 -0
  101. package/src/services/config.ts +10561 -0
  102. package/src/services/index.ts +14 -0
  103. package/src/services/nodes.ts +4313 -0
  104. package/src/services/ping.ts +43 -0
  105. package/src/services/pull.ts +118 -0
  106. package/src/services/push.ts +108 -0
  107. package/src/services/reader.ts +196 -0
  108. package/src/services/root.ts +39 -0
  109. package/src/services/status.ts +233 -0
  110. package/src/services/tape.ts +2257 -0
  111. package/src/services/version.ts +49 -0
  112. package/src/task.ts +84 -0
  113. package/src/traits.ts +46 -0
package/src/errors.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Proxmox Backup Server-specific error types.
3
+ *
4
+ * Ported from `packages/proxmox/src/errors.ts` in this same distilled
5
+ * clone, with one deliberate omission — see the ⛔ below.
6
+ *
7
+ * ⛔ NO `ClusterNodeUnreachable`, NO `patches/nodes/task-polling.json`.
8
+ * PVE's version of this file adds a `ClusterNodeUnreachable` (HTTP 595)
9
+ * error via an RFC-6902 patch to the four `/nodes/{node}/tasks/…`
10
+ * operations the VENDOR SCHEMA marks `proxyto: "node"` (PVE forwards a
11
+ * request to whichever cluster member actually owns the task). MEASURED:
12
+ * the pinned PBS schema (`~/.cache/homeflare/schemas/proxmox/
13
+ * pbs_4.2.6-1_apidoc.js`) has ZERO occurrences of `"proxyto"` anywhere —
14
+ * `grep -c '"proxyto"'` on the full 1.5 MB file returns 0. This is
15
+ * expected, not a gap: PBS is a single-host backup datastore product,
16
+ * not a cluster, so there is nothing for a request to be forwarded to.
17
+ * Carrying the 595 error and its patch forward would assert a failure
18
+ * mode the vendor schema gives no evidence for. `src/task.ts`'s
19
+ * `awaitTask` here therefore has a narrower error union than PVE's.
20
+ */
21
+ export {
22
+ BadGateway,
23
+ BadRequest,
24
+ Conflict,
25
+ ConfigError,
26
+ Forbidden,
27
+ GatewayTimeout,
28
+ InternalServerError,
29
+ Locked,
30
+ NotFound,
31
+ ServiceUnavailable,
32
+ TooManyRequests,
33
+ Unauthorized,
34
+ UnprocessableEntity,
35
+ HTTP_STATUS_MAP,
36
+ DEFAULT_ERRORS,
37
+ API_ERRORS,
38
+ } from "@distilled.cloud/core/errors";
39
+ // `export { X } from "mod"` (above) is a RE-EXPORT ONLY — it creates no local
40
+ // binding for `X`, so `BadRequest` needs its own `import type` to be usable
41
+ // in this file's own `DefaultErrors` union below.
42
+ import type {
43
+ BadRequest,
44
+ DefaultErrors as CoreDefaultErrors,
45
+ } from "@distilled.cloud/core/errors";
46
+
47
+ import * as Schema from "effect/Schema";
48
+ import * as Category from "@distilled.cloud/core/category";
49
+
50
+ /**
51
+ * PBS's generic parameter-validation failure: `400` with `{"data":null,
52
+ * "errors":{"<param>":"<message>", …}, "message":"parameter verification
53
+ * failed"}`. ⚠️ NOT INDEPENDENTLY MEASURED AGAINST A LIVE PBS HOST (this
54
+ * build makes no live PBS calls). Carried forward from PVE's identical,
55
+ * measured shape on the strength of same-vendor architecture: PBS's own
56
+ * api-viewer/apidoc.js is the same schema-driven tooling PVE's is (the
57
+ * `apidoc.ts` parser in this package reads both with only a declaration-
58
+ * syntax and terminator difference — see that file's header), and
59
+ * `taslabs-net/homeflare-kit`'s `packages/alchemy/src/proxmox/
60
+ * credentials.ts` documents PBS and PVE as literally sharing one HTTP
61
+ * client because "PBS is the same client with a different Authorization
62
+ * header" — i.e. the `{"data": …}` envelope and error shape are asserted
63
+ * identical there too, also without a live PBS credential to confirm
64
+ * against. Flagged here as INFERRED, not measured, so a future caller who
65
+ * DOES get a live token knows to re-check this specific claim first.
66
+ */
67
+ export class ParameterVerificationFailed extends Schema.TaggedError<ParameterVerificationFailed>()(
68
+ "ParameterVerificationFailed",
69
+ {
70
+ message: Schema.String,
71
+ errors: Schema.Record(Schema.String, Schema.String),
72
+ },
73
+ ).pipe(Category.withBadRequestError) {}
74
+
75
+ /**
76
+ * Unknown Proxmox Backup Server error — returned when a failed response's
77
+ * HTTP status has no mapped error class. Carries the raw body for later
78
+ * cataloging.
79
+ */
80
+ export class UnknownProxmoxBackupError extends Schema.TaggedError<UnknownProxmoxBackupError>()(
81
+ "UnknownProxmoxBackupError",
82
+ {
83
+ status: Schema.optional(Schema.Number),
84
+ message: Schema.optional(Schema.String),
85
+ body: Schema.Unknown,
86
+ },
87
+ ).pipe(Category.withServerError) {}
88
+
89
+ /** Schema parse error wrapper. */
90
+ export class ProxmoxBackupParseError extends Schema.TaggedError<ProxmoxBackupParseError>()(
91
+ "ProxmoxBackupParseError",
92
+ {
93
+ body: Schema.Unknown,
94
+ cause: Schema.Unknown,
95
+ },
96
+ ).pipe(Category.withParseError) {}
97
+
98
+ /**
99
+ * A polled task ended with an `exitstatus` other than exactly `"OK"` — see
100
+ * `src/task.ts`. `exitstatus` carries PBS's own failure text verbatim.
101
+ */
102
+ export class ProxmoxBackupTaskFailed extends Schema.TaggedError<ProxmoxBackupTaskFailed>()(
103
+ "ProxmoxBackupTaskFailed",
104
+ {
105
+ node: Schema.String,
106
+ upid: Schema.String,
107
+ exitstatus: Schema.String,
108
+ },
109
+ ) {}
110
+
111
+ /**
112
+ * Errors any Proxmox Backup Server operation may surface in addition to
113
+ * the per-operation typed status errors.
114
+ */
115
+ export type ClientErrors = UnknownProxmoxBackupError | ProxmoxBackupParseError;
116
+
117
+ /**
118
+ * Default Proxmox Backup Server operation errors: the shared HTTP status
119
+ * errors from core, PBS's global parameter-verification failure, plus the
120
+ * client-level fallback/decode errors.
121
+ */
122
+ export type DefaultErrors =
123
+ | CoreDefaultErrors
124
+ | BadRequest
125
+ | ParameterVerificationFailed
126
+ | ClientErrors;
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @distilled.cloud/proxmox-backup — Proxmox Backup Server API SDK for
3
+ * Effect.
4
+ *
5
+ * `./services` is generated by `scripts/generate.ts` from the Smithy
6
+ * models in `.generated-specs` (written by `scripts/convert.ts` from PBS's
7
+ * own `apidoc.js`, mirrored under `specs/`). Everything else in this
8
+ * folder is hand-written.
9
+ *
10
+ * One module per PBS top-level path segment — `access`, `admin`, `backup`,
11
+ * `config`, `nodes`, `ping`, `pull`, `push`, `reader`, `root`, `status`,
12
+ * `tape`, `version` — the same split the vendor's own api-viewer uses
13
+ * (measured on the pinned schema; see `scripts/convert.ts`'s header for
14
+ * per-bucket endpoint counts).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import * as ProxmoxBackup from "@distilled.cloud/proxmox-backup";
19
+ *
20
+ * const status = yield* ProxmoxBackup.Services.nodes.getNodeTaskStatus({
21
+ * node: "pbs1",
22
+ * upid: "UPID:pbs1:00001234:...",
23
+ * });
24
+ * ```
25
+ */
26
+ export * from "./credentials.ts";
27
+ export * from "./errors.ts";
28
+ export * as T from "./traits.ts";
29
+ export {
30
+ ProxmoxBackupProtocol,
31
+ type ProxmoxBackupOpError,
32
+ type ProxmoxBackupOpContext,
33
+ } from "./protocol.ts";
34
+ export * as Retry from "./retry.ts";
35
+ export * as Services from "./services/index.ts";
36
+ export { awaitTask, type TaskRef, type AwaitTaskOptions } from "./task.ts";
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Ported from `packages/proxmox/src/protocol.test.ts` in this same
3
+ * distilled clone — same bug class, same shape: a bare 400 (no `errors`
4
+ * object) must decode to the non-retryable `BadRequest`, never the
5
+ * `Category.withServerError`-tagged `UnknownProxmoxBackupError`. See
6
+ * `protocol.ts`'s `unknownError` and `errors.ts`'s module header.
7
+ *
8
+ * Adds one test PVE's file does not need: the auth header itself, because
9
+ * that is the one thing measurably different between the two packages
10
+ * (`PBSAPIToken=<id>:<secret>`, colon separator — see `credentials.ts`'s
11
+ * header) and a wrong separator fails SILENTLY AS A 401 with no other
12
+ * symptom, per that same header's citation of the kit's own warning. All
13
+ * of this runs against a fake `HttpClient` — no live PBS host is
14
+ * contacted anywhere in this file.
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+ import * as Effect from "effect/Effect";
18
+ import * as Layer from "effect/Layer";
19
+ import * as HttpClient from "effect/unstable/http/HttpClient";
20
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
21
+ import { credentials } from "./credentials.ts";
22
+ import {
23
+ BadRequest,
24
+ ParameterVerificationFailed,
25
+ UnknownProxmoxBackupError,
26
+ } from "./errors.ts";
27
+ import * as Retry from "./retry.ts";
28
+ import { getVersion } from "./services/version.ts";
29
+
30
+ const testCredentials = credentials({
31
+ tokenId: "backup@pbs!test",
32
+ secret: "test-secret",
33
+ baseUrl: "https://pbs.test:8007",
34
+ });
35
+
36
+ const fakePbs = (status: number, body: unknown) =>
37
+ Layer.succeed(
38
+ HttpClient.HttpClient,
39
+ HttpClient.make((request) =>
40
+ Effect.sync(() =>
41
+ HttpClientResponse.fromWeb(
42
+ request,
43
+ new Response(JSON.stringify(body), { status }),
44
+ ),
45
+ ),
46
+ ),
47
+ );
48
+
49
+ const call = (status: number, body: unknown) =>
50
+ Effect.runPromise(
51
+ getVersion({}).pipe(
52
+ Retry.none,
53
+ Effect.provide(Layer.mergeAll(fakePbs(status, body), testCredentials)),
54
+ Effect.flip,
55
+ ),
56
+ );
57
+
58
+ describe("protocol.ts error decoding", () => {
59
+ test("a bare 400 (no `errors` object) is BadRequest, never UnknownProxmoxBackupError", async () => {
60
+ const error = await call(400, { data: null, message: "invalid parameter" });
61
+ expect(error).toBeInstanceOf(BadRequest);
62
+ expect(error).not.toBeInstanceOf(UnknownProxmoxBackupError);
63
+ expect(error).toMatchObject({ message: "invalid parameter" });
64
+ });
65
+
66
+ test("a 400 with an `errors` object is ParameterVerificationFailed, carrying the per-field messages", async () => {
67
+ const error = await call(400, {
68
+ data: null,
69
+ message: "parameter verification failed",
70
+ errors: { comment: "value may only be 128 characters long" },
71
+ });
72
+ expect(error).toBeInstanceOf(ParameterVerificationFailed);
73
+ expect(error).toMatchObject({
74
+ message: "parameter verification failed",
75
+ errors: { comment: "value may only be 128 characters long" },
76
+ });
77
+ });
78
+
79
+ test("a genuinely unmapped, non-5xx status still falls back to UnknownProxmoxBackupError", async () => {
80
+ const error = await call(418, { data: null, message: "weird" });
81
+ expect(error).toBeInstanceOf(UnknownProxmoxBackupError);
82
+ });
83
+
84
+ test('the {"data": ...} envelope is unwrapped before decode', async () => {
85
+ const result = await Effect.runPromise(
86
+ getVersion({}).pipe(
87
+ Effect.provide(
88
+ Layer.mergeAll(
89
+ fakePbs(200, {
90
+ data: { version: "4.2.6", release: "4.2", repoid: "abc123" },
91
+ }),
92
+ testCredentials,
93
+ ),
94
+ ),
95
+ ),
96
+ );
97
+ expect(result).toMatchObject({ version: "4.2.6", release: "4.2" });
98
+ });
99
+ });
100
+
101
+ describe("credentials.ts auth header", () => {
102
+ test("Authorization is PBSAPIToken=<id>:<secret> — colon, never `=` like PVE's PVEAPIToken", async () => {
103
+ let seenAuth: string | undefined;
104
+ const capturing = Layer.succeed(
105
+ HttpClient.HttpClient,
106
+ HttpClient.make((request) =>
107
+ Effect.sync(() => {
108
+ seenAuth = request.headers["authorization"];
109
+ return HttpClientResponse.fromWeb(
110
+ request,
111
+ new Response(
112
+ JSON.stringify({
113
+ data: { version: "4.2.6", release: "4.2", repoid: "abc" },
114
+ }),
115
+ { status: 200 },
116
+ ),
117
+ );
118
+ }),
119
+ ),
120
+ );
121
+ await Effect.runPromise(
122
+ getVersion({}).pipe(
123
+ Effect.provide(Layer.mergeAll(capturing, testCredentials)),
124
+ ),
125
+ );
126
+ expect(seenAuth).toBe("PBSAPIToken=backup@pbs!test:test-secret");
127
+ });
128
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * ProxmoxBackupProtocol — the shared bearer-REST protocol instantiated for
3
+ * Proxmox Backup Server. Hand-written; `@distilled.cloud/core` is never
4
+ * changed for a provider's own quirks — they all live here.
5
+ *
6
+ * Ported from `packages/proxmox/src/protocol.ts` in this same distilled
7
+ * clone. Three of PVE's "THREE 200-TRAPS" carry forward; the fourth
8
+ * (cluster-forwarding 595) does not exist for PBS — see `src/errors.ts`'s
9
+ * header. Each trap below cites what was actually checked against the
10
+ * pinned schema (`~/.cache/homeflare/schemas/proxmox/
11
+ * pbs_4.2.6-1_apidoc.js`), versus what is carried by same-vendor
12
+ * architecture and NOT independently measured (this build makes no live
13
+ * PBS calls).
14
+ *
15
+ * ## The envelope
16
+ *
17
+ * Every PBS response is wrapped `{"data": …}`, the same as PVE.
18
+ * ⚠️ NOT independently measured live (no PBS credential exists to call
19
+ * with). Asserted, not just assumed: `taslabs-net/homeflare-kit`'s
20
+ * `packages/alchemy/src/proxmox/credentials.ts` documents PVE and PBS
21
+ * sharing ONE http client specifically BECAUSE "Proxmox Backup Server
22
+ * speaks the same `/api2/json` paths, wraps every answer in the same
23
+ * `{"data": …}` envelope" — also written before that repo had a live PBS
24
+ * credential, so this is the same vendor-architecture inference stated
25
+ * independently in two places, not two confirmations of one live check.
26
+ * `transformResponse` below unwraps it before `core/protocol-rest`'s
27
+ * schema-driven decode ever sees a payload, exactly as PVE's does.
28
+ *
29
+ * ## Authentication
30
+ *
31
+ * The API-token scheme: `Authorization: PBSAPIToken=<user>@<realm>!
32
+ * <tokenid>:<secret>` — MEASURED DIFFERENT FROM PVE: a colon separator,
33
+ * not `=`, and a `PBSAPIToken` prefix, not `PVEAPIToken`. See
34
+ * `src/credentials.ts`'s header for the two independent citations (kit
35
+ * `credentials.ts` and `pbs-prune-job.ts`) and their shared caveat
36
+ * (reasoned from PBS's documented scheme, not a live call either).
37
+ *
38
+ * ## THE TRAPS CARRIED FROM PVE (measured against the pinned schema)
39
+ *
40
+ * (a) **Async POST/PUT/DELETE answer 200 with a UPID and can fail later.**
41
+ * CONFIRMED IN SCHEMA: the pinned file's `pattern` for UPID-shaped
42
+ * strings appears 44 times, and `GET /nodes/{node}/tasks/{upid}/status`
43
+ * exists (measured: `grep -n 'tasks/{upid}/status'` finds it at line
44
+ * 25814) — the identical path shape PVE's own `awaitTask` polls.
45
+ * Handled with real code: `src/task.ts`'s `awaitTask`, unchanged in
46
+ * its `"OK"` exact-equality logic from PVE's (see that file's header
47
+ * for why `"OK (warnings)"` must not match a prefix/substring check).
48
+ *
49
+ * (b) **Some PUTs answer 200 `{"data":null}` even when nothing changed.**
50
+ * CONFIRMED IN SCHEMA: e.g. `PUT /access/acl` and `PUT /access/
51
+ * password` both declare `"returns": {"type": "null"}` (measured,
52
+ * among 53 total PUT endpoints in the pinned schema) — the same
53
+ * declared-null-return signature PVE's update endpoints carry. THIS IS
54
+ * NOT SOMETHING A PROTOCOL LAYER CAN DETECT OR FIX; documented here so
55
+ * it travels with the protocol.
56
+ *
57
+ * (c) **Permission-filtered lists return 200 + `[]` instead of 403.**
58
+ * CONFIRMED IN SCHEMA: `GET /access/acl`'s own `permissions.description`
59
+ * reads "Returns all ACLs if user has Sys.Audit on '/access/acl', or
60
+ * just the ACLs containing the user's API tokens" (measured, line 32
61
+ * of the pinned file) — the identical conditional-visibility-by-token
62
+ * phrasing the PVE package's header cites for this trap, not a guess
63
+ * by analogy. Also undetectable here for the same reason as (b).
64
+ *
65
+ * ## NOT carried: cluster-forwarding 595
66
+ *
67
+ * See `src/errors.ts`'s header — the pinned schema has zero `proxyto`
68
+ * occurrences. PBS is not a cluster product.
69
+ *
70
+ * ## Self-signed TLS
71
+ *
72
+ * Same as PVE: explicitly not handled here or anywhere in this package —
73
+ * the `HttpClient.HttpClient` layer the caller provides owns that
74
+ * decision.
75
+ */
76
+ import * as Effect from "effect/Effect";
77
+ import type * as Layer from "effect/Layer";
78
+ import * as Redacted from "effect/Redacted";
79
+ import type * as HttpClient from "effect/unstable/http/HttpClient";
80
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError";
81
+ import type * as API from "@distilled.cloud/core/api";
82
+ import {
83
+ BadRequest,
84
+ HTTP_STATUS_MAP,
85
+ type ConfigError,
86
+ } from "@distilled.cloud/core/errors";
87
+ import {
88
+ makeRestProtocol,
89
+ type RestErrorEnvelope,
90
+ } from "@distilled.cloud/core/protocol-rest";
91
+ import { Credentials, type Config } from "./credentials.ts";
92
+ import {
93
+ ParameterVerificationFailed,
94
+ UnknownProxmoxBackupError,
95
+ type DefaultErrors,
96
+ } from "./errors.ts";
97
+
98
+ /**
99
+ * Error channel shared by every generated Proxmox Backup Server operation.
100
+ * Generated service files annotate operations with
101
+ * `API.OperationMethod<I, O, ProxmoxBackupOpError, ProxmoxBackupOpContext>`
102
+ * explicitly so the compiler never infers these back out of the schema
103
+ * generics.
104
+ */
105
+ export type ProxmoxBackupOpError =
106
+ | DefaultErrors
107
+ | ConfigError
108
+ | HttpClientError.HttpClientError;
109
+
110
+ /** Context (requirements) shared by every generated Proxmox Backup Server operation. */
111
+ export type ProxmoxBackupOpContext = Credentials | HttpClient.HttpClient;
112
+
113
+ /**
114
+ * PBS's failure envelope is `{"data": null, "message": "...", "errors"?:
115
+ * {…}}` — see PVE's identical `errorEnvelope` for why `message` alone is
116
+ * read here (the `errors` sub-object is read straight from the body in
117
+ * `unknownError` below, never here).
118
+ */
119
+ const errorEnvelope = (body: unknown): RestErrorEnvelope | undefined => {
120
+ if (body === null || typeof body !== "object") return undefined;
121
+ const b = body as Record<string, unknown>;
122
+ return { message: typeof b.message === "string" ? b.message : undefined };
123
+ };
124
+
125
+ /**
126
+ * `HTTP_STATUS_MAP` minus 400 — same reasoning as PVE's copy: PBS's 400 is
127
+ * specifically the parameter-verification failure, and the generic
128
+ * `BadRequest` would throw away the per-field `errors` object.
129
+ */
130
+ const PROXMOX_BACKUP_STATUS_MAP: Record<number, new (args: any) => unknown> = {
131
+ ...HTTP_STATUS_MAP,
132
+ };
133
+ delete (PROXMOX_BACKUP_STATUS_MAP as Record<number, unknown>)[400];
134
+
135
+ export const ProxmoxBackupProtocol: Layer.Layer<API.Protocol> =
136
+ makeRestProtocol<Config>({
137
+ credentials: Effect.gen(function* () {
138
+ const resolve = yield* Credentials;
139
+ return yield* resolve;
140
+ }),
141
+ baseUrl: (creds) => creds.apiBaseUrl,
142
+ headers: (creds) => ({
143
+ // ⛔ COLON, NOT `=` — see the module header's Authentication section.
144
+ Authorization: `PBSAPIToken=${creds.tokenId}:${Redacted.value(creds.secret)}`,
145
+ Accept: "application/json",
146
+ }),
147
+ errorEnvelope,
148
+ statusMap: PROXMOX_BACKUP_STATUS_MAP,
149
+ // Unwrap PBS's `{"data": …}` envelope BEFORE the schema-driven decode —
150
+ // see the module header. `data` is `null`/absent for a Unit-output
151
+ // operation; `?? {}` is core's own convention for "no body".
152
+ transformResponse: (body) => {
153
+ if (
154
+ body !== null &&
155
+ typeof body === "object" &&
156
+ "data" in (body as Record<string, unknown>)
157
+ ) {
158
+ return (body as Record<string, unknown>).data ?? {};
159
+ }
160
+ return body;
161
+ },
162
+ // Reached for a 400 (see PROXMOX_BACKUP_STATUS_MAP above) or a status
163
+ // with no core mapping at all. `body` is the FULL parsed failure
164
+ // envelope, so this is where PBS's per-field `errors` object is read.
165
+ //
166
+ // ⛔ A BARE 400 WITHOUT THE `errors` OBJECT MUST NOT FALL INTO
167
+ // `UnknownProxmoxBackupError` — same retry-amplification hazard
168
+ // PVE's identical comment explains (`ServerError` is retried
169
+ // automatically; a malformed request is permanent). `BadRequest`
170
+ // (`Category.withBadRequestError`, not retryable) is the honest
171
+ // answer for "PBS said 400 and gave no further structure".
172
+ unknownError: ({ status, message, body }) => {
173
+ const b =
174
+ body !== null && typeof body === "object"
175
+ ? (body as Record<string, unknown>)
176
+ : undefined;
177
+ const fieldErrors = b?.errors;
178
+ if (status === 400) {
179
+ if (
180
+ fieldErrors !== null &&
181
+ typeof fieldErrors === "object" &&
182
+ !Array.isArray(fieldErrors)
183
+ ) {
184
+ const errors: Record<string, string> = {};
185
+ for (const [k, v] of Object.entries(
186
+ fieldErrors as Record<string, unknown>,
187
+ )) {
188
+ if (typeof v === "string") errors[k] = v;
189
+ }
190
+ return new ParameterVerificationFailed({ message, errors });
191
+ }
192
+ return new BadRequest({ message });
193
+ }
194
+ return new UnknownProxmoxBackupError({ status, message, body });
195
+ },
196
+ });
package/src/retry.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Proxmox Backup Server retry surface — a veneer over
3
+ * `@distilled.cloud/core/retry`. Copied unchanged from
4
+ * `packages/proxmox/src/retry.ts` (no vendor-specific logic; only the
5
+ * Context.Service tag name below is package-local).
6
+ *
7
+ * The `Retry` service tag is threaded into every generated operation via
8
+ * `API.make({ retry: Retry })`, so a caller-installed policy applies to all
9
+ * Proxmox Backup Server calls below it and core's `makeDefault` is the
10
+ * fallback when none is provided.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import * as ProxmoxBackup from "@distilled.cloud/proxmox-backup";
15
+ *
16
+ * myEffect.pipe(ProxmoxBackup.Retry.transient);
17
+ * ```
18
+ */
19
+ import * as Context from "effect/Context";
20
+ import * as Effect from "effect/Effect";
21
+ import * as Layer from "effect/Layer";
22
+ import * as Retries from "@distilled.cloud/core/retry";
23
+
24
+ export type Options = Retries.Options;
25
+ export type Factory = Retries.Factory;
26
+ export type Policy = Retries.Policy;
27
+
28
+ /** Context tag for configuring retry behavior of Proxmox Backup Server API calls. */
29
+ export class Retry extends Context.Service<Retry, Policy>()(
30
+ "ProxmoxBackupRetry",
31
+ ) {}
32
+
33
+ /** Provides a custom retry policy to every Proxmox Backup Server API call below it. */
34
+ export const policy: {
35
+ (
36
+ options: Options,
37
+ ): <A, E, R>(
38
+ effect: Effect.Effect<A, E, R>,
39
+ ) => Effect.Effect<A, E, Exclude<R, Retry>>;
40
+ (
41
+ factory: Factory,
42
+ ): <A, E, R>(
43
+ effect: Effect.Effect<A, E, R>,
44
+ ) => Effect.Effect<A, E, Exclude<R, Retry>>;
45
+ } = (optionsOrFactory: Options | Factory) =>
46
+ Effect.provide(Layer.succeed(Retry, optionsOrFactory));
47
+
48
+ /** Disables all automatic retries. */
49
+ export const none: <A, E, R>(
50
+ effect: Effect.Effect<A, E, R>,
51
+ ) => Effect.Effect<A, E, Exclude<R, Retry>> = Effect.provide(
52
+ Layer.succeed(Retry, { while: () => false }),
53
+ );
54
+
55
+ /**
56
+ * The default retry policy (core's): transient/throttling/retryable errors,
57
+ * capped exponential backoff with jitter, server `retryAfter` hints honored
58
+ * with precedence.
59
+ */
60
+ export const makeDefault: Factory = Retries.makeDefault;
61
+
62
+ export const jittered = Retries.jittered;
63
+ export const capped = Retries.capped;
64
+
65
+ /** Retry options that retry all throttling errors indefinitely. */
66
+ export const throttlingOptions: Options = Retries.throttlingOptions;
67
+
68
+ /** Retries all throttling errors indefinitely (honoring server hints). */
69
+ export const throttling: <A, E, R>(
70
+ effect: Effect.Effect<A, E, R>,
71
+ ) => Effect.Effect<A, E, Exclude<R, Retry>> = policy(Retries.throttlingFactory);
72
+
73
+ /** Retry options that retry all transient errors indefinitely. */
74
+ export const transientOptions: Options = Retries.transientOptions;
75
+
76
+ /** Retries all transient errors indefinitely (honoring server hints). */
77
+ export const transient: <A, E, R>(
78
+ effect: Effect.Effect<A, E, R>,
79
+ ) => Effect.Effect<A, E, Exclude<R, Retry>> = policy(Retries.transientFactory);