@homeflare/distilled-proxmox 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/dist/config-not-found.test.js +29 -0
- package/dist/config-not-found.test.js.map +1 -1
- package/dist/lxc-config-errors.test.d.ts +2 -0
- package/dist/lxc-config-errors.test.d.ts.map +1 -0
- package/dist/lxc-config-errors.test.js +48 -0
- package/dist/lxc-config-errors.test.js.map +1 -0
- package/dist/network-changes.test.d.ts +2 -0
- package/dist/network-changes.test.d.ts.map +1 -0
- package/dist/network-changes.test.js +42 -0
- package/dist/network-changes.test.js.map +1 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +24 -9
- package/dist/protocol.js.map +1 -1
- package/dist/qemu-config-absence.test.d.ts +2 -0
- package/dist/qemu-config-absence.test.d.ts.map +1 -0
- package/dist/qemu-config-absence.test.js +41 -0
- package/dist/qemu-config-absence.test.js.map +1 -0
- package/dist/replication-firewall-errors.test.d.ts +2 -0
- package/dist/replication-firewall-errors.test.d.ts.map +1 -0
- package/dist/replication-firewall-errors.test.js +76 -0
- package/dist/replication-firewall-errors.test.js.map +1 -0
- package/dist/services/cluster.d.ts +25 -5
- package/dist/services/cluster.d.ts.map +1 -1
- package/dist/services/cluster.js +24 -5
- package/dist/services/cluster.js.map +1 -1
- package/dist/services/nodes.d.ts +44 -8
- package/dist/services/nodes.d.ts.map +1 -1
- package/dist/services/nodes.js +49 -8
- package/dist/services/nodes.js.map +1 -1
- package/package.json +1 -1
- package/src/config-not-found.test.ts +49 -0
- package/src/lxc-config-errors.test.ts +100 -0
- package/src/network-changes.test.ts +60 -0
- package/src/protocol.ts +26 -9
- package/src/qemu-config-absence.test.ts +75 -0
- package/src/replication-firewall-errors.test.ts +126 -0
- package/src/services/cluster.ts +51 -10
- package/src/services/nodes.ts +81 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/distilled-proxmox",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Interim npm home for @distilled.cloud/proxmox — see docs/distilled-interim.md. Not published upstream yet; the kit aliases @distilled.cloud/proxmox onto this package until it is.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/distilled-proxmox#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -9,6 +9,7 @@ import type { ProxmoxOpContext } from "./protocol.ts";
|
|
|
9
9
|
import * as Retry from "./retry.ts";
|
|
10
10
|
import * as pools from "./services/pools.ts";
|
|
11
11
|
import * as cluster from "./services/cluster.ts";
|
|
12
|
+
import * as nodes from "./services/nodes.ts";
|
|
12
13
|
|
|
13
14
|
const call = <A, E>(
|
|
14
15
|
op: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
@@ -150,4 +151,52 @@ describe("precise config absence tags", () => {
|
|
|
150
151
|
}),
|
|
151
152
|
).toMatchObject({ _tag: "ParameterVerificationFailed" });
|
|
152
153
|
});
|
|
154
|
+
test("network absence is the exact sole iface error on GET and PUT", async () => {
|
|
155
|
+
const body = {
|
|
156
|
+
data: null,
|
|
157
|
+
message: "Parameter verification failed.\n",
|
|
158
|
+
errors: { iface: "interface does not exist" },
|
|
159
|
+
};
|
|
160
|
+
expect(
|
|
161
|
+
await call(
|
|
162
|
+
nodes.getNodeNetwork({ node: "n2", iface: "vmbr9" }),
|
|
163
|
+
400,
|
|
164
|
+
body,
|
|
165
|
+
),
|
|
166
|
+
).toMatchObject({ _tag: "NetworkInterfaceNotFound" });
|
|
167
|
+
// ⛔ RETAG REGRESSION'S OWN CASE — PVE's `update_network` (PUT) raises the byte-identical
|
|
168
|
+
// 400 (Network.pm:750) that `network_config` (GET) raises (:852); a consumer's catchTag
|
|
169
|
+
// keyed on this tag must see the same absence signal from either operation.
|
|
170
|
+
expect(
|
|
171
|
+
await call(
|
|
172
|
+
nodes.putNodeNetwork2({ node: "n2", iface: "vmbr9", type: "bridge" }),
|
|
173
|
+
400,
|
|
174
|
+
body,
|
|
175
|
+
),
|
|
176
|
+
).toMatchObject({ _tag: "NetworkInterfaceNotFound" });
|
|
177
|
+
for (const errors of [
|
|
178
|
+
{ iface: "invalid interface name" },
|
|
179
|
+
{ iface: "interface does not exist", type: "invalid type" },
|
|
180
|
+
{ other: "interface does not exist" },
|
|
181
|
+
]) {
|
|
182
|
+
expect(
|
|
183
|
+
await call(nodes.getNodeNetwork({ node: "n2", iface: "vmbr9" }), 400, {
|
|
184
|
+
...body,
|
|
185
|
+
errors,
|
|
186
|
+
}),
|
|
187
|
+
).toMatchObject({ _tag: "ParameterVerificationFailed", errors });
|
|
188
|
+
}
|
|
189
|
+
for (const [status, tag] of [
|
|
190
|
+
[403, "Forbidden"],
|
|
191
|
+
[500, "InternalServerError"],
|
|
192
|
+
] as const) {
|
|
193
|
+
expect(
|
|
194
|
+
await call(
|
|
195
|
+
nodes.getNodeNetwork({ node: "n2", iface: "vmbr9" }),
|
|
196
|
+
status,
|
|
197
|
+
body,
|
|
198
|
+
),
|
|
199
|
+
).toMatchObject({ _tag: tag });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
153
202
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact installed pve-container6.1.13/libpve-guest-common-perl6.0.5 absence; never generic500.
|
|
3
|
+
* ⛔ ATTACHED TO GET, PUT AND DELETE — all three call `AbstractConfig::load_config` first
|
|
4
|
+
* (patches/nodes/lxc-config-errors.json's own provenance) and raise byte-identical text; a
|
|
5
|
+
* PUT/DELETE against a container config a caller believes exists gets the same typed absence
|
|
6
|
+
* a GET would, not a generic 500 a retry policy or catchTag could mistake for transient.
|
|
7
|
+
*/
|
|
8
|
+
import { expect, test } from "bun:test";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
11
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
12
|
+
import { credentials } from "./credentials.ts";
|
|
13
|
+
import type { ProxmoxOpContext } from "./protocol.ts";
|
|
14
|
+
import * as Retry from "./retry.ts";
|
|
15
|
+
import * as nodes from "./services/nodes.ts";
|
|
16
|
+
|
|
17
|
+
const missing =
|
|
18
|
+
"Configuration file 'nodes/pve-test/lxc/900.conf' does not exist\n";
|
|
19
|
+
const call = <A, E>(
|
|
20
|
+
op: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
21
|
+
status: number,
|
|
22
|
+
message?: string,
|
|
23
|
+
) =>
|
|
24
|
+
Effect.runPromise(
|
|
25
|
+
op.pipe(
|
|
26
|
+
Retry.none,
|
|
27
|
+
Effect.provide(
|
|
28
|
+
credentials({
|
|
29
|
+
baseUrl: "https://pve.test:8006",
|
|
30
|
+
tokenId: "test@pve!test",
|
|
31
|
+
secret: "fake",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
Effect.provideService(
|
|
35
|
+
HttpClient.HttpClient,
|
|
36
|
+
HttpClient.make((request) =>
|
|
37
|
+
Effect.succeed(
|
|
38
|
+
HttpClientResponse.fromWeb(
|
|
39
|
+
request,
|
|
40
|
+
Response.json({ data: null, message }, { status }),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
Effect.flip,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
test("only exact container-config absence on GET becomes LxcConfigNotFound", async () => {
|
|
50
|
+
const get = () => nodes.getNodeLxcConfig({ node: "pve-test", vmid: "900" });
|
|
51
|
+
expect(await call(get(), 500, missing)).toMatchObject({
|
|
52
|
+
_tag: "LxcConfigNotFound",
|
|
53
|
+
});
|
|
54
|
+
for (const message of [
|
|
55
|
+
undefined,
|
|
56
|
+
"got timeout\n",
|
|
57
|
+
missing.replace("lxc", "qemu-server"),
|
|
58
|
+
missing + "other error",
|
|
59
|
+
]) {
|
|
60
|
+
expect(await call(get(), 500, message)).toMatchObject({
|
|
61
|
+
_tag: "InternalServerError",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
expect(await call(get(), 403, missing)).toMatchObject({ _tag: "Forbidden" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the same exact absence on PUT and DELETE also becomes LxcConfigNotFound", async () => {
|
|
68
|
+
expect(
|
|
69
|
+
await call(
|
|
70
|
+
nodes.putNodeLxcConfig({ node: "pve-test", vmid: "900" }),
|
|
71
|
+
500,
|
|
72
|
+
missing,
|
|
73
|
+
),
|
|
74
|
+
).toMatchObject({ _tag: "LxcConfigNotFound" });
|
|
75
|
+
expect(
|
|
76
|
+
await call(
|
|
77
|
+
nodes.deleteNodeLxc({ node: "pve-test", vmid: "900" }),
|
|
78
|
+
500,
|
|
79
|
+
missing,
|
|
80
|
+
),
|
|
81
|
+
).toMatchObject({ _tag: "LxcConfigNotFound" });
|
|
82
|
+
// ⛔ STILL NOT ABSENCE ON PUT/DELETE EITHER — a generic failure on the write paths must stay
|
|
83
|
+
// generic, exactly like the GET-only assertions above.
|
|
84
|
+
for (const message of ["got timeout\n", missing + "other error"]) {
|
|
85
|
+
expect(
|
|
86
|
+
await call(
|
|
87
|
+
nodes.putNodeLxcConfig({ node: "pve-test", vmid: "900" }),
|
|
88
|
+
500,
|
|
89
|
+
message,
|
|
90
|
+
),
|
|
91
|
+
).toMatchObject({ _tag: "InternalServerError" });
|
|
92
|
+
expect(
|
|
93
|
+
await call(
|
|
94
|
+
nodes.deleteNodeLxc({ node: "pve-test", vmid: "900" }),
|
|
95
|
+
500,
|
|
96
|
+
message,
|
|
97
|
+
),
|
|
98
|
+
).toMatchObject({ _tag: "InternalServerError" });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interfaces diff is a sibling of `data`. Generic unwrapping must not drop it,
|
|
3
|
+
* and a response without that sibling must still unwrap.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Layer from "effect/Layer";
|
|
8
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
9
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
10
|
+
import { credentials } from "./credentials.ts";
|
|
11
|
+
import type { ProxmoxOpContext } from "./protocol.ts";
|
|
12
|
+
import * as Retry from "./retry.ts";
|
|
13
|
+
import { getNodeTime, listNodeNetwork } from "./services/nodes.ts";
|
|
14
|
+
|
|
15
|
+
const creds = credentials({
|
|
16
|
+
tokenId: "root@pam!test",
|
|
17
|
+
secret: "test-secret",
|
|
18
|
+
baseUrl: "https://pve.test:8006",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const http = (body: unknown) =>
|
|
22
|
+
Layer.succeed(
|
|
23
|
+
HttpClient.HttpClient,
|
|
24
|
+
HttpClient.make((request) =>
|
|
25
|
+
Effect.succeed(
|
|
26
|
+
HttpClientResponse.fromWeb(request, Response.json(body, { status: 200 })),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const run = <A, E>(op: Effect.Effect<A, E, ProxmoxOpContext>, body: unknown) =>
|
|
32
|
+
Effect.runPromise(op.pipe(Retry.none, Effect.provide(Layer.mergeAll(http(body), creds))));
|
|
33
|
+
|
|
34
|
+
describe("network changes sibling", () => {
|
|
35
|
+
test("a staged interfaces diff survives beside data", async () => {
|
|
36
|
+
const changes = "--- a\n+++ b\n+wpa-psk secret-value\n";
|
|
37
|
+
const live = await run(
|
|
38
|
+
listNodeNetwork({ node: "pve1" }),
|
|
39
|
+
{ data: [{ iface: "vmbr0", type: "bridge" }], changes },
|
|
40
|
+
);
|
|
41
|
+
expect(live).toEqual({
|
|
42
|
+
data: [{ iface: "vmbr0", type: "bridge" }],
|
|
43
|
+
changes,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("no changes sibling still unwraps to the interface list", async () => {
|
|
48
|
+
const live = await run(listNodeNetwork({ node: "pve1" }), {
|
|
49
|
+
data: [{ iface: "vmbr0", type: "bridge" }],
|
|
50
|
+
});
|
|
51
|
+
expect(live).toEqual([{ iface: "vmbr0", type: "bridge" }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("another operation still unwraps data and ignores no sibling", async () => {
|
|
55
|
+
const live = await run(getNodeTime({ node: "pve1" }), {
|
|
56
|
+
data: { time: 1_700_000_000 },
|
|
57
|
+
});
|
|
58
|
+
expect(live).toMatchObject({ time: 1_700_000_000 });
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/protocol.ts
CHANGED
|
@@ -113,9 +113,9 @@ export type ProxmoxOpContext = Credentials | HttpClient.HttpClient;
|
|
|
113
113
|
const errorEnvelope = (body: unknown): RestErrorEnvelope | undefined => {
|
|
114
114
|
if (body === null || typeof body !== "object") return undefined;
|
|
115
115
|
const b = body as Record<string, unknown>;
|
|
116
|
-
// PVE
|
|
117
|
-
//
|
|
118
|
-
// ONLY
|
|
116
|
+
// PVE missing backup/interface/alias exceptions carry the useful message in one
|
|
117
|
+
// field (Backup.pm:406,458; Network.pm:852; Firewall/Aliases.pm:203). Expose
|
|
118
|
+
// ONLY those exact sole-field details to the operation's typed matcher.
|
|
119
119
|
// Multiple errors, other fields, and unrelated validation stay unchanged.
|
|
120
120
|
const errors = b.errors;
|
|
121
121
|
if (
|
|
@@ -124,12 +124,20 @@ const errorEnvelope = (body: unknown): RestErrorEnvelope | undefined => {
|
|
|
124
124
|
errors !== null &&
|
|
125
125
|
typeof errors === "object" &&
|
|
126
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)
|
|
127
|
+
Object.keys(errors).length === 1
|
|
131
128
|
) {
|
|
132
|
-
|
|
129
|
+
if (
|
|
130
|
+
"id" in errors &&
|
|
131
|
+
typeof errors.id === "string" &&
|
|
132
|
+
/^No such job '[^']+'$/.test(errors.id)
|
|
133
|
+
)
|
|
134
|
+
return { message: errors.id };
|
|
135
|
+
if ("name" in errors && errors.name === "no such alias") {
|
|
136
|
+
return { message: errors.name };
|
|
137
|
+
}
|
|
138
|
+
if ("iface" in errors && errors.iface === "interface does not exist") {
|
|
139
|
+
return { message: errors.iface };
|
|
140
|
+
}
|
|
133
141
|
}
|
|
134
142
|
return { message: typeof b.message === "string" ? b.message : undefined };
|
|
135
143
|
};
|
|
@@ -166,13 +174,22 @@ export const ProxmoxProtocol: Layer.Layer<API.Protocol> = withPveFormArrays(
|
|
|
166
174
|
// Unwrap PVE's `{"data": …}` envelope BEFORE the schema-driven decode —
|
|
167
175
|
// see the module header. `data` is `null`/absent for a Unit-output
|
|
168
176
|
// operation; `?? {}` is core's own convention for "no body".
|
|
177
|
+
// ⛔ `changes` is a sibling, not a field of `data`. PVE/HTTPServer.pm copies
|
|
178
|
+
// `$rpcenv->get_result_attrib('changes')` beside `data`. Network.pm sets
|
|
179
|
+
// that attrib to the interfaces diff. Dropping it makes a pending reload
|
|
180
|
+
// invisible. Only a string sibling is preserved; every other envelope
|
|
181
|
+
// still unwraps. The network list schema is the union that can decode it.
|
|
169
182
|
transformResponse: (body) => {
|
|
170
183
|
if (
|
|
171
184
|
body !== null &&
|
|
172
185
|
typeof body === "object" &&
|
|
173
186
|
"data" in (body as Record<string, unknown>)
|
|
174
187
|
) {
|
|
175
|
-
|
|
188
|
+
const record = body as Record<string, unknown>;
|
|
189
|
+
if (typeof record.changes === "string") {
|
|
190
|
+
return { changes: record.changes, data: record.data ?? [] };
|
|
191
|
+
}
|
|
192
|
+
return record.data ?? {};
|
|
176
193
|
}
|
|
177
194
|
return body;
|
|
178
195
|
},
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QEMU absence is the config-file sentence from AbstractConfig, not every 500.
|
|
3
|
+
* qemu-server Qemu.pm routes GET/PUT through `{vmid}/config` and DELETE through `{vmid}`.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Layer from "effect/Layer";
|
|
8
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
9
|
+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
|
|
10
|
+
import { credentials } from "./credentials.ts";
|
|
11
|
+
import type { ProxmoxOpContext } from "./protocol.ts";
|
|
12
|
+
import * as Retry from "./retry.ts";
|
|
13
|
+
import { deleteNodeQemu, getNodeQemuConfig } from "./services/nodes.ts";
|
|
14
|
+
|
|
15
|
+
const call = <A, E>(
|
|
16
|
+
op: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
17
|
+
status: number,
|
|
18
|
+
body: unknown,
|
|
19
|
+
) =>
|
|
20
|
+
Effect.runPromise(
|
|
21
|
+
op.pipe(
|
|
22
|
+
Retry.none,
|
|
23
|
+
Effect.provide(
|
|
24
|
+
credentials({
|
|
25
|
+
baseUrl: "https://pve.test:8006",
|
|
26
|
+
tokenId: "test@pve!test",
|
|
27
|
+
secret: "fake",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
Effect.provide(
|
|
31
|
+
Layer.succeed(
|
|
32
|
+
HttpClient.HttpClient,
|
|
33
|
+
HttpClient.make((request) =>
|
|
34
|
+
Effect.succeed(
|
|
35
|
+
HttpClientResponse.fromWeb(
|
|
36
|
+
request,
|
|
37
|
+
Response.json(body, { status }),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
Effect.flip,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const missing = {
|
|
48
|
+
data: null,
|
|
49
|
+
message: "Configuration file 'nodes/pve1/qemu-server/150.conf' does not exist\n",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("QEMU config absence", () => {
|
|
53
|
+
test("config GET and guest DELETE share the missing-file tag", async () => {
|
|
54
|
+
expect(
|
|
55
|
+
await call(getNodeQemuConfig({ node: "pve1", vmid: "150" }), 500, missing),
|
|
56
|
+
).toMatchObject({ _tag: "QemuConfigNotFound" });
|
|
57
|
+
expect(
|
|
58
|
+
await call(deleteNodeQemu({ node: "pve1", vmid: "150" }), 500, missing),
|
|
59
|
+
).toMatchObject({ _tag: "QemuConfigNotFound" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a container path, a running guest, and a bare 500 stay unrelated", async () => {
|
|
63
|
+
for (const message of [
|
|
64
|
+
"Configuration file 'nodes/pve1/lxc/150.conf' does not exist\n",
|
|
65
|
+
"VM 150 is running - destroy failed\n",
|
|
66
|
+
"unrelated failure",
|
|
67
|
+
]) {
|
|
68
|
+
const error = await call(getNodeQemuConfig({ node: "pve1", vmid: "150" }), 500, {
|
|
69
|
+
data: null,
|
|
70
|
+
message,
|
|
71
|
+
});
|
|
72
|
+
expect(error).toMatchObject({ _tag: "InternalServerError" });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Exact vendor exceptions at the real protocol boundary; source provenance is in patches. */
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
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 { ProxmoxOpContext } from "./protocol.ts";
|
|
8
|
+
import * as Retry from "./retry.ts";
|
|
9
|
+
import * as cluster from "./services/cluster.ts";
|
|
10
|
+
|
|
11
|
+
const call = <A, E>(
|
|
12
|
+
op: Effect.Effect<A, E, ProxmoxOpContext>,
|
|
13
|
+
status: number,
|
|
14
|
+
body: unknown,
|
|
15
|
+
) =>
|
|
16
|
+
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.provideService(
|
|
27
|
+
HttpClient.HttpClient,
|
|
28
|
+
HttpClient.make((request) =>
|
|
29
|
+
Effect.succeed(
|
|
30
|
+
HttpClientResponse.fromWeb(
|
|
31
|
+
request,
|
|
32
|
+
Response.json(body, { status }),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
Effect.flip,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
for (const [op, message] of [
|
|
42
|
+
[
|
|
43
|
+
cluster.getClusterReplication({ id: "900-0" }),
|
|
44
|
+
"no such replication job '900-0'\n",
|
|
45
|
+
],
|
|
46
|
+
[cluster.putClusterReplication({ id: "900-0" }), "no such job '900-0'\n"],
|
|
47
|
+
[cluster.deleteClusterReplication({ id: "900-0" }), "no such job '900-0'\n"],
|
|
48
|
+
] as const) {
|
|
49
|
+
test(`replication ${message.trim()} is typed only on its vendor status`, async () => {
|
|
50
|
+
expect(await call(op, 500, { data: null, message })).toMatchObject({
|
|
51
|
+
_tag: "ReplicationJobNotFound",
|
|
52
|
+
});
|
|
53
|
+
expect(await call(op, 403, { data: null, message })).toMatchObject({
|
|
54
|
+
_tag: "Forbidden",
|
|
55
|
+
});
|
|
56
|
+
for (const other of [
|
|
57
|
+
undefined,
|
|
58
|
+
"no such job 'unrelated'\n",
|
|
59
|
+
message + "other failure",
|
|
60
|
+
"got timeout\n",
|
|
61
|
+
]) {
|
|
62
|
+
expect(await call(op, 500, { data: null, message: other })).toMatchObject(
|
|
63
|
+
{ _tag: "InternalServerError" },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const missingAlias = {
|
|
70
|
+
data: null,
|
|
71
|
+
message: "Parameter verification failed.\n",
|
|
72
|
+
errors: { name: "no such alias" },
|
|
73
|
+
};
|
|
74
|
+
for (const op of [
|
|
75
|
+
cluster.getClusterFirewallAlias({ name: "test" }),
|
|
76
|
+
cluster.putClusterFirewallAlias({ name: "test", cidr: "192.0.2.1" }),
|
|
77
|
+
]) {
|
|
78
|
+
test("alias requires the sole exact errors.name detail", async () => {
|
|
79
|
+
expect(await call(op, 400, missingAlias)).toMatchObject({
|
|
80
|
+
_tag: "FirewallAliasNotFound",
|
|
81
|
+
});
|
|
82
|
+
expect(await call(op, 403, missingAlias)).toMatchObject({
|
|
83
|
+
_tag: "Forbidden",
|
|
84
|
+
});
|
|
85
|
+
for (const errors of [
|
|
86
|
+
{ name: "no such alias", cidr: "invalid" },
|
|
87
|
+
{ other: "no such alias" },
|
|
88
|
+
{ name: "invalid alias" },
|
|
89
|
+
]) {
|
|
90
|
+
expect(await call(op, 400, { ...missingAlias, errors })).toMatchObject({
|
|
91
|
+
_tag: "ParameterVerificationFailed",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
expect(
|
|
95
|
+
await call(op, 400, { ...missingAlias, message: "unrelated validation" }),
|
|
96
|
+
).toMatchObject({ _tag: "ParameterVerificationFailed" });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("absence patches do not classify unrelated operations", async () => {
|
|
101
|
+
expect(
|
|
102
|
+
await call(
|
|
103
|
+
cluster.createClusterFirewallAlias({ name: "test", cidr: "192.0.2.1" }),
|
|
104
|
+
400,
|
|
105
|
+
missingAlias,
|
|
106
|
+
),
|
|
107
|
+
).toMatchObject({ _tag: "ParameterVerificationFailed" });
|
|
108
|
+
expect(
|
|
109
|
+
await call(
|
|
110
|
+
cluster.deleteClusterFirewallAlias({ name: "test" }),
|
|
111
|
+
400,
|
|
112
|
+
missingAlias,
|
|
113
|
+
),
|
|
114
|
+
).toMatchObject({ _tag: "ParameterVerificationFailed" });
|
|
115
|
+
expect(
|
|
116
|
+
await call(
|
|
117
|
+
cluster.createClusterReplication({
|
|
118
|
+
id: "900-0",
|
|
119
|
+
target: "pve-b",
|
|
120
|
+
type: "local",
|
|
121
|
+
}),
|
|
122
|
+
500,
|
|
123
|
+
{ message: "no such job '900-0'\n" },
|
|
124
|
+
),
|
|
125
|
+
).toMatchObject({ _tag: "InternalServerError" });
|
|
126
|
+
});
|
package/src/services/cluster.ts
CHANGED
|
@@ -21,6 +21,18 @@ export class BackupJobNotFound
|
|
|
21
21
|
[{ status: 400, message: { matches: "^No such job '[^']+'$" } }],
|
|
22
22
|
) {}
|
|
23
23
|
|
|
24
|
+
/** Installed pve-firewall 6.0.5 PVE/API2/Firewall/Aliases.pm:203-204,252, read-only 2026-09-24; SHA256 aa4d71ea897515ca71caa8e04abd142aedf8077058649f793605f014be84db0e. GET and PUT raise HTTP400 Parameter verification failed with the sole field errors.name = no such alias. DELETE is intrinsically idempotent and has no missing-alias exception. Other validation failures remain ParameterVerificationFailed. */
|
|
25
|
+
export class FirewallAliasNotFound
|
|
26
|
+
extends /*@__PURE__*/ T.applyErrorMatchers(
|
|
27
|
+
/*@__PURE__*/ S.TaggedError<FirewallAliasNotFound>()(
|
|
28
|
+
"FirewallAliasNotFound",
|
|
29
|
+
{
|
|
30
|
+
message: S.String,
|
|
31
|
+
},
|
|
32
|
+
).pipe(C.withBadRequestError),
|
|
33
|
+
[{ status: 400, message: { matches: "^no such alias$" } }],
|
|
34
|
+
) {}
|
|
35
|
+
|
|
24
36
|
/** The metrics server does not exist. pve-manager 9.2.11, f6997e698c7933ea8e62319e2bf1bf7262daa56a, PVE/API2/Cluster/MetricServer.pm:136 throws status server entry '<id>' does not exist on GET, and line 228 throws no such server '<id>' on PUT. Both are wire 500. DELETE dereferences the plugin config without an explicit absence check and is not declared to return this tag. */
|
|
25
37
|
export class MetricServerNotFound
|
|
26
38
|
extends /*@__PURE__*/ T.applyErrorMatchers(
|
|
@@ -41,6 +53,25 @@ export class MetricServerNotFound
|
|
|
41
53
|
],
|
|
42
54
|
) {}
|
|
43
55
|
|
|
56
|
+
/** pve-manager 9.2.11 f6997e698c7933ea8e62319e2bf1bf7262daa56a PVE/API2/ReplicationConfig.pm:107,226,305. GET raises no such replication job; PUT and non-force DELETE raise no such job. Exact structured job IDs only. DELETE normally marks remove_job=full for background cleanup; an absent job is already removed. */
|
|
57
|
+
export class ReplicationJobNotFound
|
|
58
|
+
extends /*@__PURE__*/ T.applyErrorMatchers(
|
|
59
|
+
/*@__PURE__*/ S.TaggedError<ReplicationJobNotFound>()(
|
|
60
|
+
"ReplicationJobNotFound",
|
|
61
|
+
{
|
|
62
|
+
message: S.String,
|
|
63
|
+
},
|
|
64
|
+
).pipe(C.withBadRequestError),
|
|
65
|
+
[
|
|
66
|
+
{
|
|
67
|
+
status: 500,
|
|
68
|
+
message: {
|
|
69
|
+
matches: "^no such (replication )?job '[0-9]+-[0-9]+'\\n?$",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
) {}
|
|
74
|
+
|
|
44
75
|
export interface CreateClusterAcmeAccountRequest {
|
|
45
76
|
/** Contact email addresses. */
|
|
46
77
|
contact: string;
|
|
@@ -15530,7 +15561,9 @@ export const deleteClusterQemuCustomCpuModel: API.OperationMethod<
|
|
|
15530
15561
|
retry: Retry.Retry,
|
|
15531
15562
|
}));
|
|
15532
15563
|
|
|
15533
|
-
export type DeleteClusterReplicationError =
|
|
15564
|
+
export type DeleteClusterReplicationError =
|
|
15565
|
+
| ReplicationJobNotFound
|
|
15566
|
+
| ProxmoxOpError;
|
|
15534
15567
|
/** Mark replication job for removal. (root-privileged endpoint) */
|
|
15535
15568
|
export const deleteClusterReplication: API.OperationMethod<
|
|
15536
15569
|
DeleteClusterReplicationRequest,
|
|
@@ -15540,7 +15573,7 @@ export const deleteClusterReplication: API.OperationMethod<
|
|
|
15540
15573
|
> = /*@__PURE__*/ API.make(() => ({
|
|
15541
15574
|
input: DeleteClusterReplicationRequest,
|
|
15542
15575
|
output: DeleteClusterReplicationResponse,
|
|
15543
|
-
errors: [],
|
|
15576
|
+
errors: [ReplicationJobNotFound],
|
|
15544
15577
|
protocol: ProxmoxProtocol,
|
|
15545
15578
|
retry: Retry.Retry,
|
|
15546
15579
|
}));
|
|
@@ -15952,7 +15985,9 @@ export const getClusterConfigTotem: API.OperationMethod<
|
|
|
15952
15985
|
retry: Retry.Retry,
|
|
15953
15986
|
}));
|
|
15954
15987
|
|
|
15955
|
-
export type GetClusterFirewallAliasError =
|
|
15988
|
+
export type GetClusterFirewallAliasError =
|
|
15989
|
+
| FirewallAliasNotFound
|
|
15990
|
+
| ProxmoxOpError;
|
|
15956
15991
|
/** Read alias. */
|
|
15957
15992
|
export const getClusterFirewallAlias: API.OperationMethod<
|
|
15958
15993
|
GetClusterFirewallAliasRequest,
|
|
@@ -15962,7 +15997,7 @@ export const getClusterFirewallAlias: API.OperationMethod<
|
|
|
15962
15997
|
> = /*@__PURE__*/ API.make(() => ({
|
|
15963
15998
|
input: GetClusterFirewallAliasRequest,
|
|
15964
15999
|
output: GetClusterFirewallAliasResponse,
|
|
15965
|
-
errors: [],
|
|
16000
|
+
errors: [FirewallAliasNotFound],
|
|
15966
16001
|
protocol: ProxmoxProtocol,
|
|
15967
16002
|
retry: Retry.Retry,
|
|
15968
16003
|
}));
|
|
@@ -16329,7 +16364,9 @@ export const getClusterQemuCustomCpuModel: API.OperationMethod<
|
|
|
16329
16364
|
retry: Retry.Retry,
|
|
16330
16365
|
}));
|
|
16331
16366
|
|
|
16332
|
-
export type GetClusterReplicationError =
|
|
16367
|
+
export type GetClusterReplicationError =
|
|
16368
|
+
| ReplicationJobNotFound
|
|
16369
|
+
| ProxmoxOpError;
|
|
16333
16370
|
/** Read replication job configuration. */
|
|
16334
16371
|
export const getClusterReplication: API.OperationMethod<
|
|
16335
16372
|
GetClusterReplicationRequest,
|
|
@@ -16339,7 +16376,7 @@ export const getClusterReplication: API.OperationMethod<
|
|
|
16339
16376
|
> = /*@__PURE__*/ API.make(() => ({
|
|
16340
16377
|
input: GetClusterReplicationRequest,
|
|
16341
16378
|
output: GetClusterReplicationResponse,
|
|
16342
|
-
errors: [],
|
|
16379
|
+
errors: [ReplicationJobNotFound],
|
|
16343
16380
|
protocol: ProxmoxProtocol,
|
|
16344
16381
|
retry: Retry.Retry,
|
|
16345
16382
|
}));
|
|
@@ -17844,7 +17881,9 @@ export const putClusterCephHealthMute: API.OperationMethod<
|
|
|
17844
17881
|
retry: Retry.Retry,
|
|
17845
17882
|
}));
|
|
17846
17883
|
|
|
17847
|
-
export type PutClusterFirewallAliasError =
|
|
17884
|
+
export type PutClusterFirewallAliasError =
|
|
17885
|
+
| FirewallAliasNotFound
|
|
17886
|
+
| ProxmoxOpError;
|
|
17848
17887
|
/** Update IP or Network alias. (root-privileged endpoint) */
|
|
17849
17888
|
export const putClusterFirewallAlias: API.OperationMethod<
|
|
17850
17889
|
PutClusterFirewallAliasRequest,
|
|
@@ -17854,7 +17893,7 @@ export const putClusterFirewallAlias: API.OperationMethod<
|
|
|
17854
17893
|
> = /*@__PURE__*/ API.make(() => ({
|
|
17855
17894
|
input: PutClusterFirewallAliasRequest,
|
|
17856
17895
|
output: PutClusterFirewallAliasResponse,
|
|
17857
|
-
errors: [],
|
|
17896
|
+
errors: [FirewallAliasNotFound],
|
|
17858
17897
|
protocol: ProxmoxProtocol,
|
|
17859
17898
|
retry: Retry.Retry,
|
|
17860
17899
|
}));
|
|
@@ -18146,7 +18185,9 @@ export const putClusterQemuCustomCpuModel: API.OperationMethod<
|
|
|
18146
18185
|
retry: Retry.Retry,
|
|
18147
18186
|
}));
|
|
18148
18187
|
|
|
18149
|
-
export type PutClusterReplicationError =
|
|
18188
|
+
export type PutClusterReplicationError =
|
|
18189
|
+
| ReplicationJobNotFound
|
|
18190
|
+
| ProxmoxOpError;
|
|
18150
18191
|
/** Update replication job configuration. (root-privileged endpoint) */
|
|
18151
18192
|
export const putClusterReplication: API.OperationMethod<
|
|
18152
18193
|
PutClusterReplicationRequest,
|
|
@@ -18156,7 +18197,7 @@ export const putClusterReplication: API.OperationMethod<
|
|
|
18156
18197
|
> = /*@__PURE__*/ API.make(() => ({
|
|
18157
18198
|
input: PutClusterReplicationRequest,
|
|
18158
18199
|
output: PutClusterReplicationResponse,
|
|
18159
|
-
errors: [],
|
|
18200
|
+
errors: [ReplicationJobNotFound],
|
|
18160
18201
|
protocol: ProxmoxProtocol,
|
|
18161
18202
|
retry: Retry.Retry,
|
|
18162
18203
|
}));
|