@homeflare/distilled-proxmox 0.3.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/distilled-proxmox",
3
- "version": "0.3.2",
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",
@@ -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
@@ -174,13 +174,22 @@ export const ProxmoxProtocol: Layer.Layer<API.Protocol> = withPveFormArrays(
174
174
  // Unwrap PVE's `{"data": …}` envelope BEFORE the schema-driven decode —
175
175
  // see the module header. `data` is `null`/absent for a Unit-output
176
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.
177
182
  transformResponse: (body) => {
178
183
  if (
179
184
  body !== null &&
180
185
  typeof body === "object" &&
181
186
  "data" in (body as Record<string, unknown>)
182
187
  ) {
183
- return (body as Record<string, unknown>).data ?? {};
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 ?? {};
184
193
  }
185
194
  return body;
186
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
+ });
@@ -67,6 +67,23 @@ export class LxcConfigNotFound
67
67
  ],
68
68
  ) {}
69
69
 
70
+ /** qemu-server PVE/API2/Qemu.pm vm_config GET and destroy_vm both call QemuConfig->load_config. pve-guest-common AbstractConfig.pm load_config dies when the cfs file is missing: Configuration file '$cfspath' does not exist. QemuConfig's path is nodes/$node/qemu-server/$vmid.conf, HTTP 500. A missing file on this node is not a free cluster-wide vmid. */
71
+ export class QemuConfigNotFound
72
+ extends /*@__PURE__*/ T.applyErrorMatchers(
73
+ /*@__PURE__*/ S.TaggedError<QemuConfigNotFound>()("QemuConfigNotFound", {
74
+ message: S.String,
75
+ }).pipe(C.withBadRequestError),
76
+ [
77
+ {
78
+ status: 500,
79
+ message: {
80
+ matches:
81
+ "^Configuration file 'nodes/[^/']+/qemu-server/[0-9]+\\.conf' does not exist\\n?$",
82
+ },
83
+ },
84
+ ],
85
+ ) {}
86
+
70
87
  /** pve-manager 9.2.11, PVE/API2/Network.pm. GET (network_config) and PUT (update_network) both raise HTTP 400 with exactly errors.iface = interface does not exist -- verified against the live n2 source on 2026-09-25. DELETE (delete_network) raises it too but is left untyped: no generated deleteNodeNetwork2 caller exists yet. Only the exact sole-field detail is normalized for operation matching; all other validation errors remain ParameterVerificationFailed. Attached to GET and PUT. */
71
88
  export class NetworkInterfaceNotFound
72
89
  extends /*@__PURE__*/ T.applyErrorMatchers(
@@ -12574,9 +12591,28 @@ export const ListNodeNetworkResponseBodyList = /*@__PURE__*/ S.Array(
12574
12591
  ListNodeNetworkResponseBodyItem,
12575
12592
  ) as any as S.Schema<ListNodeNetworkResponseBodyList>;
12576
12593
 
12577
- export type ListNodeNetworkResponse = ListNodeNetworkResponseBodyList;
12594
+ /** Present only when PVE staged an interfaces diff. The string can contain secrets. */
12595
+ export interface ListNodeNetworkPendingResponse {
12596
+ data: ListNodeNetworkResponseBodyList;
12597
+ changes: string;
12598
+ }
12599
+ export const ListNodeNetworkPendingResponse = /*@__PURE__*/ S.suspend(() =>
12600
+ S.Struct({
12601
+ data: ListNodeNetworkResponseBodyList,
12602
+ changes: S.String,
12603
+ }),
12604
+ ).annotate({
12605
+ identifier: "ListNodeNetworkPendingResponse",
12606
+ }) as any as S.Schema<ListNodeNetworkPendingResponse>;
12607
+
12608
+ export type ListNodeNetworkResponse =
12609
+ | ListNodeNetworkResponseBodyList
12610
+ | ListNodeNetworkPendingResponse;
12578
12611
  export const ListNodeNetworkResponse = /*@__PURE__*/ S.suspend(() =>
12579
- ListNodeNetworkResponseBodyList.pipe(T.RawResponseRoot()),
12612
+ S.Union([
12613
+ ListNodeNetworkResponseBodyList.pipe(T.RawResponseRoot()),
12614
+ ListNodeNetworkPendingResponse,
12615
+ ]),
12580
12616
  ).annotate({
12581
12617
  identifier: "ListNodeNetworkResponse",
12582
12618
  }) as any as S.Schema<ListNodeNetworkResponse>;
@@ -21547,7 +21583,7 @@ export const deleteNodeNetwork2: API.OperationMethod<
21547
21583
  retry: Retry.Retry,
21548
21584
  }));
21549
21585
 
21550
- export type DeleteNodeQemuError = ProxmoxOpError;
21586
+ export type DeleteNodeQemuError = QemuConfigNotFound | ProxmoxOpError;
21551
21587
  /** Destroy the VM and all used/owned volumes. Removes any VM specific permissions and firewall rules (root-privileged endpoint) */
21552
21588
  export const deleteNodeQemu: API.OperationMethod<
21553
21589
  DeleteNodeQemuRequest,
@@ -21557,7 +21593,7 @@ export const deleteNodeQemu: API.OperationMethod<
21557
21593
  > = /*@__PURE__*/ API.make(() => ({
21558
21594
  input: DeleteNodeQemuRequest,
21559
21595
  output: DeleteNodeQemuResponse,
21560
- errors: [],
21596
+ errors: [QemuConfigNotFound],
21561
21597
  protocol: ProxmoxProtocol,
21562
21598
  retry: Retry.Retry,
21563
21599
  }));
@@ -22566,7 +22602,7 @@ export const getNodeQemuCloudinitDump: API.OperationMethod<
22566
22602
  retry: Retry.Retry,
22567
22603
  }));
22568
22604
 
22569
- export type GetNodeQemuConfigError = ProxmoxOpError;
22605
+ export type GetNodeQemuConfigError = QemuConfigNotFound | ProxmoxOpError;
22570
22606
  /** Get the virtual machine configuration with pending configuration changes applied. Set the 'current' parameter to get the current configuration instead. */
22571
22607
  export const getNodeQemuConfig: API.OperationMethod<
22572
22608
  GetNodeQemuConfigRequest,
@@ -22576,7 +22612,7 @@ export const getNodeQemuConfig: API.OperationMethod<
22576
22612
  > = /*@__PURE__*/ API.make(() => ({
22577
22613
  input: GetNodeQemuConfigRequest,
22578
22614
  output: GetNodeQemuConfigResponse,
22579
- errors: [],
22615
+ errors: [QemuConfigNotFound],
22580
22616
  protocol: ProxmoxProtocol,
22581
22617
  retry: Retry.Retry,
22582
22618
  }));