@homeflare/distilled-proxmox-backup 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -17
- package/dist/errors.d.ts +6 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/missing-resource.test.d.ts +2 -0
- package/dist/missing-resource.test.d.ts.map +1 -0
- package/dist/missing-resource.test.js +62 -0
- package/dist/missing-resource.test.js.map +1 -0
- package/dist/protocol-boundaries.test.d.ts +2 -0
- package/dist/protocol-boundaries.test.d.ts.map +1 -0
- package/dist/protocol-boundaries.test.js +50 -0
- package/dist/protocol-boundaries.test.js.map +1 -0
- package/dist/protocol-codecs.d.ts +23 -0
- package/dist/protocol-codecs.d.ts.map +1 -0
- package/dist/protocol-codecs.js +49 -0
- package/dist/protocol-codecs.js.map +1 -0
- package/dist/protocol-codecs.test.d.ts +2 -0
- package/dist/protocol-codecs.test.d.ts.map +1 -0
- package/dist/protocol-codecs.test.js +58 -0
- package/dist/protocol-codecs.test.js.map +1 -0
- package/dist/protocol-raw-root.test.d.ts +2 -0
- package/dist/protocol-raw-root.test.d.ts.map +1 -0
- package/dist/protocol-raw-root.test.js +48 -0
- package/dist/protocol-raw-root.test.js.map +1 -0
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +4 -1
- package/dist/protocol.js.map +1 -1
- package/dist/services/config.d.ts +44 -4
- package/dist/services/config.d.ts.map +1 -1
- package/dist/services/config.js +29 -4
- package/dist/services/config.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +7 -11
- package/src/missing-resource.test.ts +98 -0
- package/src/protocol-boundaries.test.ts +60 -0
- package/src/protocol-codecs.test.ts +112 -0
- package/src/protocol-codecs.ts +75 -0
- package/src/protocol-raw-root.test.ts +84 -0
- package/src/protocol.ts +62 -58
- package/src/services/config.ts +45 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/distilled-proxmox-backup",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Interim npm home for @distilled.cloud/proxmox-backup — see docs/distilled-interim.md. Not published upstream yet; the kit aliases @distilled.cloud/proxmox-backup onto this package until it is.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/distilled-proxmox-backup#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
package/src/errors.ts
CHANGED
|
@@ -37,12 +37,8 @@ export {
|
|
|
37
37
|
API_ERRORS,
|
|
38
38
|
} from "@distilled.cloud/core/errors";
|
|
39
39
|
// `export { X } from "mod"` (above) is a RE-EXPORT ONLY — it creates no local
|
|
40
|
-
// binding for `X`, so
|
|
41
|
-
|
|
42
|
-
import type {
|
|
43
|
-
BadRequest,
|
|
44
|
-
DefaultErrors as CoreDefaultErrors,
|
|
45
|
-
} from "@distilled.cloud/core/errors";
|
|
40
|
+
// binding for `X`, so the API error tuple needs a type import for its union below.
|
|
41
|
+
import type { API_ERRORS } from "@distilled.cloud/core/errors";
|
|
46
42
|
|
|
47
43
|
import * as Schema from "effect/Schema";
|
|
48
44
|
import * as Category from "@distilled.cloud/core/category";
|
|
@@ -115,12 +111,12 @@ export class ProxmoxBackupTaskFailed extends Schema.TaggedError<ProxmoxBackupTas
|
|
|
115
111
|
export type ClientErrors = UnknownProxmoxBackupError | ProxmoxBackupParseError;
|
|
116
112
|
|
|
117
113
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
114
|
+
* Every status class the protocol's HTTP_STATUS_MAP may return, plus PBS failures.
|
|
115
|
+
* Core's DefaultErrors is only its infrastructure subset; using that here hid mapped
|
|
116
|
+
* NotFound/Forbidden/Conflict from the typed operation union, so a legitimate catchTag
|
|
117
|
+
* could not compile even though that exact error was returned at runtime.
|
|
121
118
|
*/
|
|
122
119
|
export type DefaultErrors =
|
|
123
|
-
|
|
|
124
|
-
| BadRequest
|
|
120
|
+
| InstanceType<(typeof API_ERRORS)[number]>
|
|
125
121
|
| ParameterVerificationFailed
|
|
126
122
|
| ClientErrors;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Read-only PBS wire measurements, 2026-09-24: missing config items use plain-text 400. */
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
6
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
7
|
+
import { credentials } from "./credentials.ts";
|
|
8
|
+
import type { ProxmoxBackupOpContext } from "./protocol.ts";
|
|
9
|
+
import * as Retry from "./retry.ts";
|
|
10
|
+
import * as config from "./services/config.ts";
|
|
11
|
+
|
|
12
|
+
const testCredentials = credentials({
|
|
13
|
+
tokenId: "backup@pbs!test",
|
|
14
|
+
secret: "test-secret",
|
|
15
|
+
baseUrl: "https://pbs.test:8007",
|
|
16
|
+
});
|
|
17
|
+
const failure = (
|
|
18
|
+
op: Effect.Effect<unknown, unknown, ProxmoxBackupOpContext>,
|
|
19
|
+
status: number,
|
|
20
|
+
body: string,
|
|
21
|
+
) =>
|
|
22
|
+
Effect.runPromise(
|
|
23
|
+
op.pipe(
|
|
24
|
+
Retry.none,
|
|
25
|
+
Effect.provide(
|
|
26
|
+
Layer.mergeAll(
|
|
27
|
+
testCredentials,
|
|
28
|
+
Layer.succeed(
|
|
29
|
+
HttpClient.HttpClient,
|
|
30
|
+
HttpClient.make((request) =>
|
|
31
|
+
Effect.succeed(
|
|
32
|
+
HttpClientResponse.fromWeb(
|
|
33
|
+
request,
|
|
34
|
+
new Response(body, { status }),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
Effect.flip,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
const absent = "hf-absent-sdk-0924";
|
|
45
|
+
const cases = [
|
|
46
|
+
{
|
|
47
|
+
op: config.getConfigDatastore({ name: absent }),
|
|
48
|
+
kind: "datastore",
|
|
49
|
+
tag: "DatastoreNotFound",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
op: config.getConfigPrune({ id: absent }),
|
|
53
|
+
kind: "prune",
|
|
54
|
+
tag: "PruneJobNotFound",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
op: config.getConfigSync({ id: absent }),
|
|
58
|
+
kind: "sync",
|
|
59
|
+
tag: "SyncJobNotFound",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
op: config.getConfigVerify({ id: absent }),
|
|
63
|
+
kind: "verification",
|
|
64
|
+
tag: "VerifyJobNotFound",
|
|
65
|
+
},
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
describe("PBS resource absence", () => {
|
|
69
|
+
for (const { op, kind, tag } of cases) {
|
|
70
|
+
test(`${kind}: exact measured 400 is a typed missing resource`, async () => {
|
|
71
|
+
expect(
|
|
72
|
+
await failure(op, 400, `no such ${kind} '${absent}'`),
|
|
73
|
+
).toMatchObject({ _tag: tag });
|
|
74
|
+
});
|
|
75
|
+
test(`${kind}: a different status, kind or extended error is not absence`, async () => {
|
|
76
|
+
for (const [status, body] of [
|
|
77
|
+
[403, `no such ${kind} '${absent}'`],
|
|
78
|
+
[500, `no such ${kind} '${absent}'`],
|
|
79
|
+
[400, "no such unrelated 'id'"],
|
|
80
|
+
[400, `no such ${kind} '${absent}': configuration unreadable`],
|
|
81
|
+
[400, "parameter verification failed"],
|
|
82
|
+
] as const) {
|
|
83
|
+
expect(await failure(op, status, body)).not.toMatchObject({
|
|
84
|
+
_tag: tag,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
test("missing text is scoped to the named GET operation", async () => {
|
|
90
|
+
expect(
|
|
91
|
+
await failure(
|
|
92
|
+
config.listConfigDatastore({}),
|
|
93
|
+
400,
|
|
94
|
+
`no such datastore '${absent}'`,
|
|
95
|
+
),
|
|
96
|
+
).not.toMatchObject({ _tag: "DatastoreNotFound" });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The PBS adapter preserves core's decoded-value and nested-form contracts. */
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import * as API from "@distilled.cloud/core/api";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
8
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
9
|
+
import { withPbsCodecs } from "./protocol-codecs.ts";
|
|
10
|
+
|
|
11
|
+
const request = HttpClientRequest.post("https://pbs.test/config").pipe(
|
|
12
|
+
HttpClientRequest.bodyUrlParams([
|
|
13
|
+
["target[0]", "mail"],
|
|
14
|
+
["target[1]", "hook"],
|
|
15
|
+
["nested[targets][0]", "keep"],
|
|
16
|
+
["scalar", "a,b"],
|
|
17
|
+
]),
|
|
18
|
+
);
|
|
19
|
+
const base = Layer.succeed(
|
|
20
|
+
API.Protocol,
|
|
21
|
+
API.Protocol.of({
|
|
22
|
+
encode: () => Effect.succeed(request),
|
|
23
|
+
decode: () => Effect.succeed(42),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
const protocol = withPbsCodecs(base);
|
|
27
|
+
|
|
28
|
+
test("only top-level indexed form lists change", async () => {
|
|
29
|
+
const encoded = await Effect.runPromise(
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const adapter = yield* API.Protocol;
|
|
32
|
+
return yield* adapter.encode({
|
|
33
|
+
input: {},
|
|
34
|
+
inputAst: Schema.Unknown.ast,
|
|
35
|
+
config: {},
|
|
36
|
+
});
|
|
37
|
+
}).pipe(Effect.provide(protocol)),
|
|
38
|
+
);
|
|
39
|
+
expect(encoded.body._tag).toBe("Uint8Array");
|
|
40
|
+
if (encoded.body._tag !== "Uint8Array") return;
|
|
41
|
+
const form = new URLSearchParams(new TextDecoder().decode(encoded.body.body));
|
|
42
|
+
expect(form.getAll("target")).toEqual(["mail", "hook"]);
|
|
43
|
+
expect(form.get("nested[targets][0]")).toBe("keep");
|
|
44
|
+
expect(form.get("scalar")).toBe("a,b");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("core-decoded output is validated on the Type side without decoding twice", async () => {
|
|
48
|
+
const result = await Effect.runPromise(
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const adapter = yield* API.Protocol;
|
|
51
|
+
return yield* adapter.decode({
|
|
52
|
+
response: HttpClientResponse.fromWeb(request, new Response("42")),
|
|
53
|
+
outputAst: Schema.NumberFromString.ast,
|
|
54
|
+
config: { output: Schema.NumberFromString },
|
|
55
|
+
errors: [],
|
|
56
|
+
});
|
|
57
|
+
}).pipe(Effect.provide(protocol)),
|
|
58
|
+
);
|
|
59
|
+
expect(result).toBe(42);
|
|
60
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
5
|
+
import type { HttpClientRequest } from "effect/unstable/http/HttpClientRequest";
|
|
6
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
7
|
+
import { credentials } from "./credentials.ts";
|
|
8
|
+
import * as Retry from "./retry.ts";
|
|
9
|
+
import {
|
|
10
|
+
getConfigNotificationMatcher,
|
|
11
|
+
putConfigNotificationMatcher,
|
|
12
|
+
} from "./services/config.ts";
|
|
13
|
+
|
|
14
|
+
const credential = credentials({
|
|
15
|
+
baseUrl: "https://pbs.test:8007",
|
|
16
|
+
tokenId: "test@pbs!fake",
|
|
17
|
+
secret: "fake-not-real",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const fakePbs = (body: unknown, see?: (request: HttpClientRequest) => void) =>
|
|
21
|
+
Layer.succeed(
|
|
22
|
+
HttpClient.HttpClient,
|
|
23
|
+
HttpClient.make((request) =>
|
|
24
|
+
Effect.sync(() => {
|
|
25
|
+
see?.(request);
|
|
26
|
+
return HttpClientResponse.fromWeb(
|
|
27
|
+
request,
|
|
28
|
+
Response.json({ data: body }),
|
|
29
|
+
);
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
test("PBS arrays use repeated keys and Unit data:null remains a successful write", async () => {
|
|
35
|
+
let pairs: [string, string][] = [];
|
|
36
|
+
const client = fakePbs(null, (request) => {
|
|
37
|
+
expect(request.body._tag).toBe("Uint8Array");
|
|
38
|
+
if (request.body._tag === "Uint8Array") {
|
|
39
|
+
pairs = [
|
|
40
|
+
...new URLSearchParams(new TextDecoder().decode(request.body.body)),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
await Effect.runPromise(
|
|
45
|
+
putConfigNotificationMatcher({
|
|
46
|
+
name: "pager",
|
|
47
|
+
target: ["mail-to-root", "hook"],
|
|
48
|
+
delete: ["match-field", "match-calendar"],
|
|
49
|
+
}).pipe(Retry.none, Effect.provide(Layer.mergeAll(client, credential))),
|
|
50
|
+
);
|
|
51
|
+
expect(pairs.filter(([key]) => key === "target")).toEqual([
|
|
52
|
+
["target", "mail-to-root"],
|
|
53
|
+
["target", "hook"],
|
|
54
|
+
]);
|
|
55
|
+
expect(pairs.filter(([key]) => key === "delete")).toEqual([
|
|
56
|
+
["delete", "match-field"],
|
|
57
|
+
["delete", "match-calendar"],
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
for (const body of [null, { comment: "secret-like-text-must-not-appear" }]) {
|
|
62
|
+
test("incomplete resource output fails without exposing response contents", async () => {
|
|
63
|
+
const error = await Effect.runPromise(
|
|
64
|
+
getConfigNotificationMatcher({ name: "pager" }).pipe(
|
|
65
|
+
Retry.none,
|
|
66
|
+
Effect.flip,
|
|
67
|
+
Effect.provide(Layer.mergeAll(fakePbs(body), credential)),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
expect(error).toMatchObject({ _tag: "ProxmoxBackupParseError" });
|
|
71
|
+
expect(JSON.stringify(error)).not.toContain(
|
|
72
|
+
"secret-like-text-must-not-appear",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("valid resource output preserves fields omitted from the vendor schema", async () => {
|
|
78
|
+
const result = await Effect.runPromise(
|
|
79
|
+
getConfigNotificationMatcher({ name: "pager" }).pipe(
|
|
80
|
+
Retry.none,
|
|
81
|
+
Effect.provide(
|
|
82
|
+
Layer.mergeAll(
|
|
83
|
+
fakePbs({ name: "pager", future_field: "keep-me" }),
|
|
84
|
+
credential,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
expect(result).toMatchObject({ name: "pager", future_field: "keep-me" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("mapped NotFound is in the operation's typed union, including a plain-text response", async () => {
|
|
93
|
+
const client = Layer.succeed(
|
|
94
|
+
HttpClient.HttpClient,
|
|
95
|
+
HttpClient.make((request) =>
|
|
96
|
+
Effect.succeed(
|
|
97
|
+
HttpClientResponse.fromWeb(
|
|
98
|
+
request,
|
|
99
|
+
new Response("matcher 'pager' not found", { status: 404 }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const result = await Effect.runPromise(
|
|
105
|
+
getConfigNotificationMatcher({ name: "pager" }).pipe(
|
|
106
|
+
Retry.none,
|
|
107
|
+
Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
|
|
108
|
+
Effect.provide(Layer.mergeAll(client, credential)),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
expect(result).toBeUndefined();
|
|
112
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PBS 4.2.6-1's form arrays and successful response validation.
|
|
3
|
+
*
|
|
4
|
+
* The vendor's array parameters are repeated keys (`target=a&target=b`), not the Stripe
|
|
5
|
+
* bracket-index encoding used by core's generic REST builder. Measured against the PBS
|
|
6
|
+
* notification provider's existing protocol fixtures, 2026-09-24: indexed headers/secrets
|
|
7
|
+
* disappear from its read-back, and matchers perpetually drift. Preserve core path/auth/key
|
|
8
|
+
* mapping and adapt only its top-level indexed form fields at this vendor boundary.
|
|
9
|
+
*/
|
|
10
|
+
import * as API from "@distilled.cloud/core/api";
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as Layer from "effect/Layer";
|
|
13
|
+
import * as Schema from "effect/Schema";
|
|
14
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
15
|
+
import { ProxmoxBackupParseError } from "./errors.ts";
|
|
16
|
+
|
|
17
|
+
const repeatedFormKeys = (request: HttpClientRequest.HttpClientRequest) => {
|
|
18
|
+
if (
|
|
19
|
+
request.body._tag !== "Uint8Array" ||
|
|
20
|
+
request.body.contentType !== "application/x-www-form-urlencoded"
|
|
21
|
+
)
|
|
22
|
+
return request;
|
|
23
|
+
const fields = new URLSearchParams(
|
|
24
|
+
new TextDecoder().decode(request.body.body),
|
|
25
|
+
);
|
|
26
|
+
const repeated: [string, string][] = [];
|
|
27
|
+
for (const [key, value] of fields) {
|
|
28
|
+
repeated.push([key.replace(/^([^\[\]]+)\[\d+\]$/, "$1"), value]);
|
|
29
|
+
}
|
|
30
|
+
return HttpClientRequest.bodyUrlParams(request, repeated);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Core REST decode maps wire keys but does not validate required fields. In particular,
|
|
35
|
+
* PBS data:null unwrapped to {} used to look like a real resource to a caller. Validate
|
|
36
|
+
* with the operation's generated schema: Unit operations still accept {}, while a GET
|
|
37
|
+
* missing its required name/id fails. Preserve the mapped payload after validation: vendor
|
|
38
|
+
* fields absent from the published schema must not silently disappear. Core has already
|
|
39
|
+
* decoded the payload, so validate the schema's Type side instead of re-decoding transforms.
|
|
40
|
+
* Keep server data out of the parse error: notification responses can contain header
|
|
41
|
+
* credentials, and a schema diagnostic can reproduce them.
|
|
42
|
+
*/
|
|
43
|
+
export const withPbsCodecs = (
|
|
44
|
+
base: Layer.Layer<API.Protocol>,
|
|
45
|
+
): Layer.Layer<API.Protocol> =>
|
|
46
|
+
Layer.effect(
|
|
47
|
+
API.Protocol,
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const protocol = yield* API.Protocol;
|
|
50
|
+
return API.Protocol.of({
|
|
51
|
+
encode: (args) =>
|
|
52
|
+
protocol.encode(args).pipe(Effect.map(repeatedFormKeys)),
|
|
53
|
+
decode: (args) =>
|
|
54
|
+
protocol.decode(args).pipe(
|
|
55
|
+
Effect.flatMap((body) =>
|
|
56
|
+
args.config.output === undefined
|
|
57
|
+
? Effect.succeed(body)
|
|
58
|
+
: (Schema.decodeUnknownEffect(
|
|
59
|
+
Schema.toType(args.config.output),
|
|
60
|
+
)(body).pipe(
|
|
61
|
+
Effect.as(body),
|
|
62
|
+
Effect.mapError(
|
|
63
|
+
() =>
|
|
64
|
+
new ProxmoxBackupParseError({
|
|
65
|
+
body: undefined,
|
|
66
|
+
cause:
|
|
67
|
+
"Response does not match the operation's declared output schema",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
) as Effect.Effect<unknown>),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
}),
|
|
75
|
+
).pipe(Layer.provide(base));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
5
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
6
|
+
import { credentials } from "./credentials.ts";
|
|
7
|
+
import type { ProxmoxBackupOpContext } from "./protocol.ts";
|
|
8
|
+
import * as Retry from "./retry.ts";
|
|
9
|
+
import {
|
|
10
|
+
deleteConfigDatastore,
|
|
11
|
+
getConfigAcmeTos,
|
|
12
|
+
listConfigDatastore,
|
|
13
|
+
} from "./services/config.ts";
|
|
14
|
+
|
|
15
|
+
const run = <A, E>(
|
|
16
|
+
operation: Effect.Effect<A, E, ProxmoxBackupOpContext>,
|
|
17
|
+
data: unknown,
|
|
18
|
+
) =>
|
|
19
|
+
Effect.runPromise(
|
|
20
|
+
operation.pipe(
|
|
21
|
+
Retry.none,
|
|
22
|
+
Effect.provide(
|
|
23
|
+
Layer.mergeAll(
|
|
24
|
+
credentials({
|
|
25
|
+
baseUrl: "https://pbs.test:8007",
|
|
26
|
+
tokenId: "test@pbs!fake",
|
|
27
|
+
secret: "fake-not-real",
|
|
28
|
+
}),
|
|
29
|
+
Layer.succeed(
|
|
30
|
+
HttpClient.HttpClient,
|
|
31
|
+
HttpClient.make((request) =>
|
|
32
|
+
Effect.succeed(
|
|
33
|
+
HttpClientResponse.fromWeb(request, Response.json({ data })),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
test("raw-root string responses retain task IDs and ACME terms URLs", async () => {
|
|
43
|
+
const upid =
|
|
44
|
+
"UPID:pbs:00000001:00000002:00000003:delete-datastore:test:root@pam:";
|
|
45
|
+
expect(await run(deleteConfigDatastore({ name: "test" }), upid)).toBe(upid);
|
|
46
|
+
const url = "https://acme.test/terms";
|
|
47
|
+
expect(await run(getConfigAcmeTos({}), url)).toBe(url);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("raw-root lists retain empty lists, map wire keys, and preserve extra fields", async () => {
|
|
51
|
+
expect(await run(listConfigDatastore({}), [])).toEqual([]);
|
|
52
|
+
const expected = [
|
|
53
|
+
{ name: "test", path: "/backup/test", keep_last: 2, future_field: "keep" },
|
|
54
|
+
];
|
|
55
|
+
expect(
|
|
56
|
+
await run(listConfigDatastore({}), [
|
|
57
|
+
{
|
|
58
|
+
name: "test",
|
|
59
|
+
path: "/backup/test",
|
|
60
|
+
"keep-last": 2,
|
|
61
|
+
future_field: "keep",
|
|
62
|
+
},
|
|
63
|
+
]),
|
|
64
|
+
).toEqual(expected);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// These operations are not Unit responses: null must not become a valid empty object.
|
|
68
|
+
for (const data of [null, {}]) {
|
|
69
|
+
test(`raw-root operations reject ${JSON.stringify(data)} instead of accepting missing output`, async () => {
|
|
70
|
+
const operations: Effect.Effect<
|
|
71
|
+
unknown,
|
|
72
|
+
unknown,
|
|
73
|
+
ProxmoxBackupOpContext
|
|
74
|
+
>[] = [
|
|
75
|
+
deleteConfigDatastore({ name: "test" }),
|
|
76
|
+
getConfigAcmeTos({}),
|
|
77
|
+
listConfigDatastore({}),
|
|
78
|
+
];
|
|
79
|
+
for (const operation of operations) {
|
|
80
|
+
const error = await run(Effect.flip(operation), data);
|
|
81
|
+
expect(error).toMatchObject({ _tag: "ProxmoxBackupParseError" });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
type RestErrorEnvelope,
|
|
90
90
|
} from "@distilled.cloud/core/protocol-rest";
|
|
91
91
|
import { Credentials, type Config } from "./credentials.ts";
|
|
92
|
+
import { withPbsCodecs } from "./protocol-codecs.ts";
|
|
92
93
|
import {
|
|
93
94
|
ParameterVerificationFailed,
|
|
94
95
|
UnknownProxmoxBackupError,
|
|
@@ -132,65 +133,68 @@ const PROXMOX_BACKUP_STATUS_MAP: Record<number, new (args: any) => unknown> = {
|
|
|
132
133
|
};
|
|
133
134
|
delete (PROXMOX_BACKUP_STATUS_MAP as Record<number, unknown>)[400];
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
136
|
+
const baseProtocol: Layer.Layer<API.Protocol> = 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) {
|
|
153
179
|
if (
|
|
154
|
-
|
|
155
|
-
typeof
|
|
156
|
-
|
|
180
|
+
fieldErrors !== null &&
|
|
181
|
+
typeof fieldErrors === "object" &&
|
|
182
|
+
!Array.isArray(fieldErrors)
|
|
157
183
|
) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 });
|
|
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;
|
|
191
189
|
}
|
|
192
|
-
return new
|
|
190
|
+
return new ParameterVerificationFailed({ message, errors });
|
|
193
191
|
}
|
|
194
|
-
return new
|
|
195
|
-
}
|
|
196
|
-
|
|
192
|
+
return new BadRequest({ message });
|
|
193
|
+
}
|
|
194
|
+
return new UnknownProxmoxBackupError({ status, message, body });
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** PBS-specific form encoding and schema validation wrap core without changing other vendors. */
|
|
199
|
+
export const ProxmoxBackupProtocol: Layer.Layer<API.Protocol> =
|
|
200
|
+
withPbsCodecs(baseProtocol);
|