@homeflare/distilled-proxmox 0.2.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 +15 -10
- package/dist/config-not-found.test.d.ts +2 -0
- package/dist/config-not-found.test.d.ts.map +1 -0
- package/dist/config-not-found.test.js +96 -0
- package/dist/config-not-found.test.js.map +1 -0
- package/dist/delete-query-binding.test.d.ts +2 -0
- package/dist/delete-query-binding.test.d.ts.map +1 -0
- package/dist/delete-query-binding.test.js +49 -0
- package/dist/delete-query-binding.test.js.map +1 -0
- package/dist/errors.d.ts +14 -7
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -5
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/not-found-errors.test.d.ts +2 -0
- package/dist/not-found-errors.test.d.ts.map +1 -0
- package/dist/not-found-errors.test.js +59 -0
- package/dist/not-found-errors.test.js.map +1 -0
- package/dist/protocol-form.d.ts +18 -0
- package/dist/protocol-form.d.ts.map +1 -0
- package/dist/protocol-form.js +37 -0
- package/dist/protocol-form.js.map +1 -0
- package/dist/protocol-form.test.d.ts +2 -0
- package/dist/protocol-form.test.d.ts.map +1 -0
- package/dist/protocol-form.test.js +68 -0
- package/dist/protocol-form.test.js.map +1 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +28 -9
- package/dist/protocol.js.map +1 -1
- package/dist/services/access.d.ts +22 -2
- package/dist/services/access.d.ts.map +1 -1
- package/dist/services/access.js +17 -9
- package/dist/services/access.js.map +1 -1
- package/dist/services/cluster.d.ts +25 -5
- package/dist/services/cluster.d.ts.map +1 -1
- package/dist/services/cluster.js +56 -82
- package/dist/services/cluster.js.map +1 -1
- package/dist/services/nodes.d.ts +22 -2
- package/dist/services/nodes.d.ts.map +1 -1
- package/dist/services/nodes.js +60 -72
- package/dist/services/nodes.js.map +1 -1
- package/dist/services/pools.d.ts +16 -6
- package/dist/services/pools.d.ts.map +1 -1
- package/dist/services/pools.js +20 -13
- package/dist/services/pools.js.map +1 -1
- package/dist/services/storage.d.ts +11 -1
- package/dist/services/storage.d.ts.map +1 -1
- package/dist/services/storage.js +8 -1
- package/dist/services/storage.js.map +1 -1
- package/package.json +1 -7
- package/src/config-not-found.test.ts +153 -0
- package/src/delete-query-binding.test.ts +65 -0
- package/src/errors.ts +16 -17
- package/src/index.ts +0 -1
- package/src/not-found-errors.test.ts +112 -0
- package/src/protocol-form.test.ts +102 -0
- package/src/protocol-form.ts +49 -0
- package/src/protocol.ts +31 -9
- package/src/services/access.ts +25 -11
- package/src/services/cluster.ts +76 -89
- package/src/services/nodes.ts +71 -74
- package/src/services/pools.ts +29 -21
- package/src/services/storage.ts +12 -2
- package/dist/task.d.ts +0 -51
- package/dist/task.d.ts.map +0 -1
- package/dist/task.js +0 -60
- package/dist/task.js.map +0 -1
- package/src/task.ts +0 -87
package/src/errors.ts
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
* - `ClusterNodeUnreachable` is added via an ACTUAL RFC-6902 patch —
|
|
24
24
|
* `patches/nodes/task-polling.json` — to the four `/nodes/{node}/
|
|
25
25
|
* tasks/…` operations the vendor schema itself marks `proxyto: "node"`
|
|
26
|
-
* (
|
|
26
|
+
* (a caller polling `GetNodeTaskStatus` — this package's own generated
|
|
27
|
+
* operation — can hit any of the four; task polling itself is
|
|
28
|
+
* provider-side, not this package's, per P13/Q3 of the 2026-09-24
|
|
29
|
+
* walk-down: no distilled precedent for a package-level poll helper).
|
|
30
|
+
* It is NOT
|
|
27
31
|
* global: most PVE calls are answered by the node you connected to
|
|
28
32
|
* directly and never proxy at all, so declaring a 595 possible on
|
|
29
33
|
* every operation would be a claim the schema does not support for
|
|
@@ -48,16 +52,9 @@ export {
|
|
|
48
52
|
DEFAULT_ERRORS,
|
|
49
53
|
API_ERRORS,
|
|
50
54
|
} from "@distilled.cloud/core/errors";
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
// of this package typechecking under `verbatimModuleSyntax`, not by this
|
|
55
|
-
// package's own `tsc -b` — see `docs/distilled-interim.md`'s "prove it in
|
|
56
|
-
// both places" step for why that gap exists at all.
|
|
57
|
-
import type {
|
|
58
|
-
BadRequest,
|
|
59
|
-
DefaultErrors as CoreDefaultErrors,
|
|
60
|
-
} from "@distilled.cloud/core/errors";
|
|
55
|
+
// Re-exports do not bind names locally. The operation union must cover every
|
|
56
|
+
// class the package protocol can return through core's HTTP_STATUS_MAP.
|
|
57
|
+
import type { API_ERRORS } from "@distilled.cloud/core/errors";
|
|
61
58
|
|
|
62
59
|
import * as Schema from "effect/Schema";
|
|
63
60
|
import * as Category from "@distilled.cloud/core/category";
|
|
@@ -118,10 +115,13 @@ export class ProxmoxParseError extends Schema.TaggedError<ProxmoxParseError>()(
|
|
|
118
115
|
).pipe(Category.withParseError) {}
|
|
119
116
|
|
|
120
117
|
/**
|
|
121
|
-
* A polled task ended with an `exitstatus` other than exactly `"OK"
|
|
122
|
-
* `
|
|
123
|
-
*
|
|
124
|
-
*
|
|
118
|
+
* A polled task ended with an `exitstatus` other than exactly `"OK"`.
|
|
119
|
+
* `exitstatus` carries PVE's own failure text verbatim (e.g. `"job
|
|
120
|
+
* errors"`, `"OK (warnings)"` — the latter is why a caller's compare must
|
|
121
|
+
* be exact-equality against `"OK"`, never a prefix/substring check).
|
|
122
|
+
* Exported for a provider-side poll to construct: this package no longer
|
|
123
|
+
* ships its own `awaitTask` helper (removed 2026-09-24 — polling belongs
|
|
124
|
+
* in the provider, P13; no distilled precedent for a package-level poll).
|
|
125
125
|
*/
|
|
126
126
|
export class ProxmoxTaskFailed extends Schema.TaggedError<ProxmoxTaskFailed>()(
|
|
127
127
|
"ProxmoxTaskFailed",
|
|
@@ -146,7 +146,6 @@ export type ClientErrors = UnknownProxmoxError | ProxmoxParseError;
|
|
|
146
146
|
* task-polling.json` adds it to.
|
|
147
147
|
*/
|
|
148
148
|
export type DefaultErrors =
|
|
149
|
-
|
|
|
150
|
-
| BadRequest
|
|
149
|
+
| InstanceType<(typeof API_ERRORS)[number]>
|
|
151
150
|
| ParameterVerificationFailed
|
|
152
151
|
| ClientErrors;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WI-3: proves each resource-specific not-found shape actually decodes
|
|
3
|
+
* from PVE's real wire response — not just that the class exists and
|
|
4
|
+
* carries a non-transient category (checked separately, by hand, with
|
|
5
|
+
* `Category.isTransientError` against one instance of each class; see the
|
|
6
|
+
* WI-3 report). Each fixture here is the literal or closely-paraphrased
|
|
7
|
+
* text cited in the corresponding `patches/<segment>/_errors.json`
|
|
8
|
+
* patch's own description.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as Layer from "effect/Layer";
|
|
13
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
14
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
15
|
+
import { credentials } from "./credentials.ts";
|
|
16
|
+
import type { ProxmoxOpContext } from "./protocol.ts";
|
|
17
|
+
import * as Retry from "./retry.ts";
|
|
18
|
+
import {
|
|
19
|
+
getAccessGroup,
|
|
20
|
+
getAccessUser,
|
|
21
|
+
GroupNotFound,
|
|
22
|
+
UserNotFound,
|
|
23
|
+
} from "./services/access.ts";
|
|
24
|
+
import {
|
|
25
|
+
CephFsNotFound,
|
|
26
|
+
CephPoolNotFound,
|
|
27
|
+
deleteNodeCephFs,
|
|
28
|
+
getNodeCephPoolStatus,
|
|
29
|
+
} from "./services/nodes.ts";
|
|
30
|
+
import { getStorage, StorageNotFound } from "./services/storage.ts";
|
|
31
|
+
|
|
32
|
+
const testCredentials = credentials({
|
|
33
|
+
tokenId: "root@pam!test",
|
|
34
|
+
secret: "test-secret",
|
|
35
|
+
baseUrl: "https://pve.test:8006",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const fakePve = (status: number, body: unknown) =>
|
|
39
|
+
Layer.succeed(
|
|
40
|
+
HttpClient.HttpClient,
|
|
41
|
+
HttpClient.make((request) =>
|
|
42
|
+
Effect.sync(() =>
|
|
43
|
+
HttpClientResponse.fromWeb(
|
|
44
|
+
request,
|
|
45
|
+
new Response(JSON.stringify(body), { status }),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const flipped = <A, E>(
|
|
52
|
+
effect: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
53
|
+
status: number,
|
|
54
|
+
body: unknown,
|
|
55
|
+
) =>
|
|
56
|
+
Effect.runPromise(
|
|
57
|
+
effect.pipe(
|
|
58
|
+
Retry.none,
|
|
59
|
+
Effect.provide(Layer.mergeAll(fakePve(status, body), testCredentials)),
|
|
60
|
+
Effect.flip,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
describe("WI-3 typed not-found errors decode from PVE's real wire shape", () => {
|
|
65
|
+
test("a missing user's 500 decodes to UserNotFound", async () => {
|
|
66
|
+
const error = await flipped(getAccessUser({ userid: "x@pve" }), 500, {
|
|
67
|
+
message: "no such user ('x@pve')",
|
|
68
|
+
});
|
|
69
|
+
expect(error).toBeInstanceOf(UserNotFound);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a missing group's 500 decodes to GroupNotFound", async () => {
|
|
73
|
+
const error = await flipped(getAccessGroup({ groupid: "ghosts" }), 500, {
|
|
74
|
+
message: "group 'ghosts' does not exist",
|
|
75
|
+
});
|
|
76
|
+
expect(error).toBeInstanceOf(GroupNotFound);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a missing storage's 500 decodes to StorageNotFound", async () => {
|
|
80
|
+
const error = await flipped(
|
|
81
|
+
getStorage({ storage: "hf-measure-nonexistent-probe" }),
|
|
82
|
+
500,
|
|
83
|
+
{
|
|
84
|
+
message: "storage 'hf-measure-nonexistent-probe' does not exist\n",
|
|
85
|
+
data: null,
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
expect(error).toBeInstanceOf(StorageNotFound);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a missing Ceph pool's 500 decodes to CephPoolNotFound", async () => {
|
|
92
|
+
const error = await flipped(
|
|
93
|
+
getNodeCephPoolStatus({ node: "n2", name: "ghost-pool" }),
|
|
94
|
+
500,
|
|
95
|
+
{
|
|
96
|
+
data: null,
|
|
97
|
+
message:
|
|
98
|
+
"error with 'osd pool get': mon_cmd failed - unrecognized pool 'ghost-pool'\n",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
expect(error).toBeInstanceOf(CephPoolNotFound);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a missing Ceph filesystem's 500 (on destroy) decodes to CephFsNotFound", async () => {
|
|
105
|
+
const error = await flipped(
|
|
106
|
+
deleteNodeCephFs({ node: "n2", name: "ghost-fs" }),
|
|
107
|
+
500,
|
|
108
|
+
{ message: "no such cephfs 'ghost-fs'\n" },
|
|
109
|
+
);
|
|
110
|
+
expect(error).toBeInstanceOf(CephFsNotFound);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** PVE 9.2.11 schema arrays must survive the vendor's repeated-key decoder. */
|
|
2
|
+
import { 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 HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
7
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
8
|
+
import { credentials } from "./credentials.ts";
|
|
9
|
+
import type { ProxmoxOpContext } from "./protocol.ts";
|
|
10
|
+
import * as Retry from "./retry.ts";
|
|
11
|
+
import * as cluster from "./services/cluster.ts";
|
|
12
|
+
import * as nodes from "./services/nodes.ts";
|
|
13
|
+
|
|
14
|
+
const capture = async <A, E>(op: Effect.Effect<A, E, ProxmoxOpContext>) => {
|
|
15
|
+
const requests: HttpClientRequest.HttpClientRequest[] = [];
|
|
16
|
+
await Effect.runPromise(
|
|
17
|
+
op.pipe(
|
|
18
|
+
Retry.none,
|
|
19
|
+
Effect.provide(
|
|
20
|
+
credentials({
|
|
21
|
+
baseUrl: "https://pve.test:8006",
|
|
22
|
+
tokenId: "test@pve!test",
|
|
23
|
+
secret: "fake",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
Effect.provide(
|
|
27
|
+
Layer.succeed(
|
|
28
|
+
HttpClient.HttpClient,
|
|
29
|
+
HttpClient.make((request) => {
|
|
30
|
+
requests.push(request);
|
|
31
|
+
return Effect.succeed(
|
|
32
|
+
HttpClientResponse.fromWeb(
|
|
33
|
+
request,
|
|
34
|
+
Response.json({ data: null }),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
const request = requests[0]!;
|
|
43
|
+
const form =
|
|
44
|
+
request.body._tag === "Uint8Array"
|
|
45
|
+
? new URLSearchParams(new TextDecoder().decode(request.body.body))
|
|
46
|
+
: new URLSearchParams();
|
|
47
|
+
return { request, form };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
test("matcher arrays repeat the wire key; commas within a rule stay inside that item", async () => {
|
|
51
|
+
const { request, form } = await capture(
|
|
52
|
+
cluster.createClusterNotificationMatcher({
|
|
53
|
+
name: "matcher",
|
|
54
|
+
target: ["mail", "webhook"],
|
|
55
|
+
match_field: ["exact:type=vzdump", "regex:hostname=one,two"],
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
expect(request.method).toBe("POST");
|
|
59
|
+
expect(form.getAll("target")).toEqual(["mail", "webhook"]);
|
|
60
|
+
expect(form.getAll("match-field")).toEqual([
|
|
61
|
+
"exact:type=vzdump",
|
|
62
|
+
"regex:hostname=one,two",
|
|
63
|
+
]);
|
|
64
|
+
expect([...form.keys()].some((key) => key.includes("["))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("webhook property-string headers remain separate values", async () => {
|
|
68
|
+
const headers = ["name=X-One,value=YQ==", "name=X-Two,value=Yg=="];
|
|
69
|
+
const { form } = await capture(
|
|
70
|
+
cluster.createClusterNotificationEndpointWebhook({
|
|
71
|
+
name: "webhook",
|
|
72
|
+
method: "post",
|
|
73
|
+
url: "https://example.com/hook",
|
|
74
|
+
header: headers,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(form.getAll("header")).toEqual(headers);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("array delete fields repeat while scalar comma lists remain scalar", async () => {
|
|
81
|
+
const { form } = await capture(
|
|
82
|
+
cluster.putClusterNotificationMatcher({
|
|
83
|
+
name: "matcher",
|
|
84
|
+
delete: ["match-field", "match-calendar"],
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
expect(form.getAll("delete")).toEqual(["match-field", "match-calendar"]);
|
|
88
|
+
const scalar = await capture(
|
|
89
|
+
cluster.putClusterBackup({ id: "job", vmid: "100,101" }),
|
|
90
|
+
);
|
|
91
|
+
expect(scalar.form.getAll("vmid")).toEqual(["100,101"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("DELETE stays bodyless and its flags stay query parameters", async () => {
|
|
95
|
+
const { request, form } = await capture(
|
|
96
|
+
nodes.deleteNodeCephFs({ node: "node", name: "fs", remove_pools: "1" }),
|
|
97
|
+
);
|
|
98
|
+
expect(request.method).toBe("DELETE");
|
|
99
|
+
expect(request.body._tag).toBe("Empty");
|
|
100
|
+
expect(new URL(request.url).searchParams.get("remove-pools")).toBe("1");
|
|
101
|
+
expect([...form]).toEqual([]);
|
|
102
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PVE's form arrays use repeated keys, never Stripe's indexed fields.
|
|
3
|
+
*
|
|
4
|
+
* PVE 9.2.11 notification schemas declare arrays (mailto, header, target,
|
|
5
|
+
* match-field, delete). The vendor UI submits repeated keys; its Perl
|
|
6
|
+
* URL-encoded decoder joins them with NUL before schema validation. The
|
|
7
|
+
* existing kit notification-target-form.ts records that decoder/UI evidence
|
|
8
|
+
* (2026-09-14); no production multi-header write was made for this fix.
|
|
9
|
+
*
|
|
10
|
+
* Keep core's credentials, labels, schema key mapping, and query encoding.
|
|
11
|
+
* Only top-level scalar list entries in a form body change: header[0]=a
|
|
12
|
+
* becomes header=a. A property-string item's commas remain inside the item.
|
|
13
|
+
* A scalar comma list, an embedded NUL, or a GET/DELETE query is unchanged.
|
|
14
|
+
*/
|
|
15
|
+
import * as API from "@distilled.cloud/core/api";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import * as Layer from "effect/Layer";
|
|
18
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
19
|
+
|
|
20
|
+
const repeatedFormKeys = (request: HttpClientRequest.HttpClientRequest) => {
|
|
21
|
+
if (
|
|
22
|
+
request.body._tag !== "Uint8Array" ||
|
|
23
|
+
request.body.contentType !== "application/x-www-form-urlencoded"
|
|
24
|
+
)
|
|
25
|
+
return request;
|
|
26
|
+
const fields = new URLSearchParams(
|
|
27
|
+
new TextDecoder().decode(request.body.body),
|
|
28
|
+
);
|
|
29
|
+
const repeated: [string, string][] = [];
|
|
30
|
+
for (const [key, value] of fields) {
|
|
31
|
+
repeated.push([key.replace(/^([^\[\]]+)\[\d+\]$/, "$1"), value]);
|
|
32
|
+
}
|
|
33
|
+
return HttpClientRequest.bodyUrlParams(request, repeated);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const withPveFormArrays = (
|
|
37
|
+
base: Layer.Layer<API.Protocol>,
|
|
38
|
+
): Layer.Layer<API.Protocol> =>
|
|
39
|
+
Layer.effect(
|
|
40
|
+
API.Protocol,
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const protocol = yield* API.Protocol;
|
|
43
|
+
return API.Protocol.of({
|
|
44
|
+
...protocol,
|
|
45
|
+
encode: (args) =>
|
|
46
|
+
protocol.encode(args).pipe(Effect.map(repeatedFormKeys)),
|
|
47
|
+
});
|
|
48
|
+
}),
|
|
49
|
+
).pipe(Layer.provide(base));
|
package/src/protocol.ts
CHANGED
|
@@ -24,13 +24,15 @@
|
|
|
24
24
|
* (a) **Async POST/PUT/DELETE answer 200 with a UPID and can fail later.**
|
|
25
25
|
* PVE hands back a bare task id string (`"UPID:node:...);"`) for any
|
|
26
26
|
* long-running action — a VM start, a backup, a storage scan — and the
|
|
27
|
-
* 200 means "the task was QUEUED", not "the task succeeded".
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* 200 means "the task was QUEUED", not "the task succeeded". A caller
|
|
28
|
+
* must poll `GetNodeTaskStatus` (this package's own generated
|
|
29
|
+
* operation) until `exitstatus` is present, then treat anything other
|
|
30
|
+
* than EXACTLY `"OK"` as a failure — PVE also answers `"OK
|
|
31
|
+
* (warnings)"`, so a bare-prefix match is wrong. This package ships
|
|
32
|
+
* the typed `ProxmoxTaskFailed` for that comparison but not the poll
|
|
33
|
+
* loop itself: polling is provider-side (P13 of the 2026-09-24
|
|
34
|
+
* walk-down; no distilled precedent for a package-level poll helper —
|
|
35
|
+
* see `errors.ts`'s `ProxmoxTaskFailed` doc for the removal note).
|
|
34
36
|
*
|
|
35
37
|
* (b) **Some PUTs answer 200 `{"data":null}` even when nothing changed.**
|
|
36
38
|
* PVE's update handlers commonly return `null` unconditionally on
|
|
@@ -79,6 +81,7 @@ import {
|
|
|
79
81
|
type RestErrorEnvelope,
|
|
80
82
|
} from "@distilled.cloud/core/protocol-rest";
|
|
81
83
|
import { Credentials, type Config } from "./credentials.ts";
|
|
84
|
+
import { withPveFormArrays } from "./protocol-form.ts";
|
|
82
85
|
import {
|
|
83
86
|
ParameterVerificationFailed,
|
|
84
87
|
UnknownProxmoxError,
|
|
@@ -110,6 +113,24 @@ export type ProxmoxOpContext = Credentials | HttpClient.HttpClient;
|
|
|
110
113
|
const errorEnvelope = (body: unknown): RestErrorEnvelope | undefined => {
|
|
111
114
|
if (body === null || typeof body !== "object") return undefined;
|
|
112
115
|
const b = body as Record<string, unknown>;
|
|
116
|
+
// PVE's backup read/delete missing-id exception is a 400 with the useful
|
|
117
|
+
// message in errors.id (Backup.pm:406,458 at pve-manager 9.2.11). Expose
|
|
118
|
+
// ONLY that exact sole-field detail to the operation's typed matcher.
|
|
119
|
+
// Multiple errors, other fields, and unrelated validation stay unchanged.
|
|
120
|
+
const errors = b.errors;
|
|
121
|
+
if (
|
|
122
|
+
(b.message === "Parameter verification failed." ||
|
|
123
|
+
b.message === "Parameter verification failed.\n") &&
|
|
124
|
+
errors !== null &&
|
|
125
|
+
typeof errors === "object" &&
|
|
126
|
+
!Array.isArray(errors) &&
|
|
127
|
+
Object.keys(errors).length === 1 &&
|
|
128
|
+
"id" in errors &&
|
|
129
|
+
typeof errors.id === "string" &&
|
|
130
|
+
/^No such job '[^']+'$/.test(errors.id)
|
|
131
|
+
) {
|
|
132
|
+
return { message: errors.id };
|
|
133
|
+
}
|
|
113
134
|
return { message: typeof b.message === "string" ? b.message : undefined };
|
|
114
135
|
};
|
|
115
136
|
|
|
@@ -126,7 +147,7 @@ const PROXMOX_STATUS_MAP: Record<number, new (args: any) => unknown> = {
|
|
|
126
147
|
};
|
|
127
148
|
delete (PROXMOX_STATUS_MAP as Record<number, unknown>)[400];
|
|
128
149
|
|
|
129
|
-
export const ProxmoxProtocol: Layer.Layer<API.Protocol> =
|
|
150
|
+
export const ProxmoxProtocol: Layer.Layer<API.Protocol> = withPveFormArrays(
|
|
130
151
|
makeRestProtocol<Config>({
|
|
131
152
|
// Resolved on the CALLING fiber per request (the layer is memoized per
|
|
132
153
|
// process); the Credentials service holds an effect, so a token rotated
|
|
@@ -201,4 +222,5 @@ export const ProxmoxProtocol: Layer.Layer<API.Protocol> =
|
|
|
201
222
|
}
|
|
202
223
|
return new UnknownProxmoxError({ status, message, body });
|
|
203
224
|
},
|
|
204
|
-
})
|
|
225
|
+
}),
|
|
226
|
+
);
|
package/src/services/access.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/generate.ts from .generated-specs (apidata.js, via scripts/apidoc.ts — see its header). Do not edit.
|
|
2
2
|
import * as S from "@distilled.cloud/core/schema";
|
|
3
3
|
import * as API from "@distilled.cloud/core/api";
|
|
4
|
+
import * as C from "@distilled.cloud/core/category";
|
|
4
5
|
import * as T from "../traits.ts";
|
|
5
6
|
import {
|
|
6
7
|
ProxmoxProtocol,
|
|
@@ -11,6 +12,24 @@ import * as Retry from "../retry.ts";
|
|
|
11
12
|
|
|
12
13
|
export type { ProxmoxOpError, ProxmoxOpContext };
|
|
13
14
|
|
|
15
|
+
/** The group does not exist. PVE answers this as HTTP 500, the same shape as UserNotFound. Traced to source, not paraphrased: `pve-access-control` (commit dff26b458651b4e1141ff0a41a296d2904b16228, package version 9.1.2) `src/PVE/API2/Group.pm:201`, inside `register_method({ name => 'read_group', path => '{groupid}', method => 'GET', description => "Get group configuration." })` — matching this operation's own doc string exactly: `die "group '$group' does not exist\n" if !$data;`. Corroborated by the kit's own prior wire evidence: `taslabs-net/homeflare-kit`'s `packages/alchemy/src/proxmox/group-wire.ts:80-81` ("MEASURED against the live cluster: `GET /access/groups/{missing}` answers a genuine 500"). */
|
|
16
|
+
export class GroupNotFound
|
|
17
|
+
extends /*@__PURE__*/ T.applyErrorMatchers(
|
|
18
|
+
/*@__PURE__*/ S.TaggedError<GroupNotFound>()("GroupNotFound", {
|
|
19
|
+
message: S.String,
|
|
20
|
+
}).pipe(C.withBadRequestError),
|
|
21
|
+
[{ status: 500, message: { matches: "^group '[^']*' does not exist" } }],
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/** The user does not exist. PVE answers this as HTTP 500 with a bare `{"message":"no such user ('<userid>')"}` — this shape states the honest status (404) while matching the wire's real one. Traced to source, not paraphrased: `pve-access-control` (git.proxmox.com/git/pve-access-control.git, commit dff26b458651b4e1141ff0a41a296d2904b16228, package version 9.1.2) `src/PVE/AccessControl.pm:678`, `sub check_user_exist`: `die "no such user ('$username')\n" if !$noerr;`. Reachability confirmed, not assumed: `src/PVE/API2/User.pm`'s `GET .../users/{userid}` handler (`description => "Get user configuration."`, matching this operation's own doc string exactly) calls `PVE::AccessControl::check_user_exist($usercfg, $username)` at line ~415 with no `$noerr` argument, so `!$noerr` is true and the die fires whenever `$username` is absent from `user.cfg`. Corroborated by the kit's own prior wire evidence: `taslabs-net/homeflare-kit`'s `packages/alchemy/src/proxmox/user.test.ts:46` fixture, `no such user ('${userid}')` at 500. */
|
|
25
|
+
export class UserNotFound
|
|
26
|
+
extends /*@__PURE__*/ T.applyErrorMatchers(
|
|
27
|
+
/*@__PURE__*/ S.TaggedError<UserNotFound>()("UserNotFound", {
|
|
28
|
+
message: S.String,
|
|
29
|
+
}).pipe(C.withBadRequestError),
|
|
30
|
+
[{ status: 500, message: { matches: "^no such user \\(" } }],
|
|
31
|
+
) {}
|
|
32
|
+
|
|
14
33
|
export type CreateAccessDomainRequestMode = "ldap" | "ldaps" | "ldap+starttls";
|
|
15
34
|
export const CreateAccessDomainRequestMode = S.String;
|
|
16
35
|
|
|
@@ -479,14 +498,9 @@ export const DeleteAccessTfaRequest = /*@__PURE__*/ S.suspend(() =>
|
|
|
479
498
|
S.Struct({
|
|
480
499
|
userid: S.String.pipe(T.Label()),
|
|
481
500
|
id: S.String.pipe(T.Label()),
|
|
482
|
-
password: S.optional(S.String),
|
|
501
|
+
password: S.optional(S.String.pipe(T.Query())),
|
|
483
502
|
}).pipe(
|
|
484
|
-
T.Http({
|
|
485
|
-
method: "DELETE",
|
|
486
|
-
uri: "/access/tfa/{userid}/{id}",
|
|
487
|
-
code: 200,
|
|
488
|
-
contentType: "form-urlencoded",
|
|
489
|
-
}),
|
|
503
|
+
T.Http({ method: "DELETE", uri: "/access/tfa/{userid}/{id}", code: 200 }),
|
|
490
504
|
),
|
|
491
505
|
).annotate({
|
|
492
506
|
identifier: "DeleteAccessTfaRequest",
|
|
@@ -2256,7 +2270,7 @@ export const getAccessDomain: API.OperationMethod<
|
|
|
2256
2270
|
retry: Retry.Retry,
|
|
2257
2271
|
}));
|
|
2258
2272
|
|
|
2259
|
-
export type GetAccessGroupError = ProxmoxOpError;
|
|
2273
|
+
export type GetAccessGroupError = GroupNotFound | ProxmoxOpError;
|
|
2260
2274
|
/** Get group configuration. */
|
|
2261
2275
|
export const getAccessGroup: API.OperationMethod<
|
|
2262
2276
|
GetAccessGroupRequest,
|
|
@@ -2266,7 +2280,7 @@ export const getAccessGroup: API.OperationMethod<
|
|
|
2266
2280
|
> = /*@__PURE__*/ API.make(() => ({
|
|
2267
2281
|
input: GetAccessGroupRequest,
|
|
2268
2282
|
output: GetAccessGroupResponse,
|
|
2269
|
-
errors: [],
|
|
2283
|
+
errors: [GroupNotFound],
|
|
2270
2284
|
protocol: ProxmoxProtocol,
|
|
2271
2285
|
retry: Retry.Retry,
|
|
2272
2286
|
}));
|
|
@@ -2346,7 +2360,7 @@ export const getAccessTicket: API.OperationMethod<
|
|
|
2346
2360
|
retry: Retry.Retry,
|
|
2347
2361
|
}));
|
|
2348
2362
|
|
|
2349
|
-
export type GetAccessUserError = ProxmoxOpError;
|
|
2363
|
+
export type GetAccessUserError = UserNotFound | ProxmoxOpError;
|
|
2350
2364
|
/** Get user configuration. */
|
|
2351
2365
|
export const getAccessUser: API.OperationMethod<
|
|
2352
2366
|
GetAccessUserRequest,
|
|
@@ -2356,7 +2370,7 @@ export const getAccessUser: API.OperationMethod<
|
|
|
2356
2370
|
> = /*@__PURE__*/ API.make(() => ({
|
|
2357
2371
|
input: GetAccessUserRequest,
|
|
2358
2372
|
output: GetAccessUserResponse,
|
|
2359
|
-
errors: [],
|
|
2373
|
+
errors: [UserNotFound],
|
|
2360
2374
|
protocol: ProxmoxProtocol,
|
|
2361
2375
|
retry: Retry.Retry,
|
|
2362
2376
|
}));
|