@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.
- package/LICENSE +201 -0
- package/README.md +102 -0
- package/dist/credentials.d.ts +50 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +105 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +73 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +19 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +158 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocol.test.d.ts +2 -0
- package/dist/protocol.test.d.ts.map +1 -0
- package/dist/protocol.test.js +75 -0
- package/dist/protocol.test.js.map +1 -0
- package/dist/retry.d.ts +53 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +46 -0
- package/dist/retry.js.map +1 -0
- package/dist/services/access.d.ts +587 -0
- package/dist/services/access.d.ts.map +1 -0
- package/dist/services/access.js +787 -0
- package/dist/services/access.js.map +1 -0
- package/dist/services/admin.d.ts +1110 -0
- package/dist/services/admin.d.ts.map +1 -0
- package/dist/services/admin.js +1428 -0
- package/dist/services/admin.js.map +1 -0
- package/dist/services/backup.d.ts +224 -0
- package/dist/services/backup.d.ts.map +1 -0
- package/dist/services/backup.js +325 -0
- package/dist/services/backup.js.map +1 -0
- package/dist/services/config.d.ts +3662 -0
- package/dist/services/config.d.ts.map +1 -0
- package/dist/services/config.js +4445 -0
- package/dist/services/config.js.map +1 -0
- package/dist/services/index.d.ts +14 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +15 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/nodes.d.ts +1441 -0
- package/dist/services/nodes.d.ts.map +1 -0
- package/dist/services/nodes.js +1829 -0
- package/dist/services/nodes.js.map +1 -0
- package/dist/services/ping.d.ts +15 -0
- package/dist/services/ping.d.ts.map +1 -0
- package/dist/services/ping.js +21 -0
- package/dist/services/ping.js.map +1 -0
- package/dist/services/pull.d.ts +54 -0
- package/dist/services/pull.d.ts.map +1 -0
- package/dist/services/pull.js +47 -0
- package/dist/services/pull.js.map +1 -0
- package/dist/services/push.d.ts +50 -0
- package/dist/services/push.d.ts.map +1 -0
- package/dist/services/push.js +45 -0
- package/dist/services/push.js.map +1 -0
- package/dist/services/reader.d.ts +68 -0
- package/dist/services/reader.d.ts.map +1 -0
- package/dist/services/reader.js +89 -0
- package/dist/services/reader.js.map +1 -0
- package/dist/services/root.d.ts +14 -0
- package/dist/services/root.d.ts.map +1 -0
- package/dist/services/root.js +19 -0
- package/dist/services/root.js.map +1 -0
- package/dist/services/status.d.ts +78 -0
- package/dist/services/status.d.ts.map +1 -0
- package/dist/services/status.js +97 -0
- package/dist/services/status.js.map +1 -0
- package/dist/services/tape.d.ts +711 -0
- package/dist/services/tape.d.ts.map +1 -0
- package/dist/services/tape.js +986 -0
- package/dist/services/tape.js.map +1 -0
- package/dist/services/version.d.ts +17 -0
- package/dist/services/version.d.ts.map +1 -0
- package/dist/services/version.js +25 -0
- package/dist/services/version.js.map +1 -0
- package/dist/task.d.ts +48 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +57 -0
- package/dist/task.js.map +1 -0
- package/dist/traits.d.ts +13 -0
- package/dist/traits.d.ts.map +1 -0
- package/dist/traits.js +15 -0
- package/dist/traits.js.map +1 -0
- package/package.json +81 -0
- package/src/credentials.ts +139 -0
- package/src/errors.ts +126 -0
- package/src/index.ts +36 -0
- package/src/protocol.test.ts +128 -0
- package/src/protocol.ts +196 -0
- package/src/retry.ts +79 -0
- package/src/services/access.ts +1787 -0
- package/src/services/admin.ts +3258 -0
- package/src/services/backup.ts +698 -0
- package/src/services/config.ts +10561 -0
- package/src/services/index.ts +14 -0
- package/src/services/nodes.ts +4313 -0
- package/src/services/ping.ts +43 -0
- package/src/services/pull.ts +118 -0
- package/src/services/push.ts +108 -0
- package/src/services/reader.ts +196 -0
- package/src/services/root.ts +39 -0
- package/src/services/status.ts +233 -0
- package/src/services/tape.ts +2257 -0
- package/src/services/version.ts +49 -0
- package/src/task.ts +84 -0
- 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
|
+
});
|
package/src/protocol.ts
ADDED
|
@@ -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);
|