From c3d2367c35f08f86e325d7f4709f90097f5df9d3 Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 18:40:06 +0800 Subject: [PATCH 01/19] feat(services): add Proxmox VE 8.3.5 service package (issue #103) --- services/bin/octobus-tentacles.js | 4 + services/bin/ve-8-3-5.js | 10 + services/package.json | 3 + services/proxmox__ve_8-3-5/README.md | 72 ++ services/proxmox__ve_8-3-5/bin/ve-8-3-5.js | 6 + services/proxmox__ve_8-3-5/config.schema.json | 92 +++ services/proxmox__ve_8-3-5/package.json | 12 + .../proto/proxmox_ve_8_3_5.proto | 251 ++++++ services/proxmox__ve_8-3-5/secret.schema.json | 35 + services/proxmox__ve_8-3-5/service.json | 49 ++ services/proxmox__ve_8-3-5/src/service.js | 7 + services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 718 ++++++++++++++++++ .../proxmox__ve_8-3-5/test/mock_upstream.js | 249 ++++++ .../proxmox__ve_8-3-5/test/ve-8-3-5.test.js | 685 +++++++++++++++++ 14 files changed, 2193 insertions(+) create mode 100644 services/bin/ve-8-3-5.js create mode 100644 services/proxmox__ve_8-3-5/README.md create mode 100644 services/proxmox__ve_8-3-5/bin/ve-8-3-5.js create mode 100644 services/proxmox__ve_8-3-5/config.schema.json create mode 100644 services/proxmox__ve_8-3-5/package.json create mode 100644 services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto create mode 100644 services/proxmox__ve_8-3-5/secret.schema.json create mode 100644 services/proxmox__ve_8-3-5/service.json create mode 100644 services/proxmox__ve_8-3-5/src/service.js create mode 100644 services/proxmox__ve_8-3-5/src/ve-8-3-5.js create mode 100644 services/proxmox__ve_8-3-5/test/mock_upstream.js create mode 100644 services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index ccf90344..b8a86d1f 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -501,6 +501,10 @@ const services = { entryFile: "../elastic__elasticsearch_7-10-0/bin/elasticsearch-7-10-0.js", serviceModule: "../elastic__elasticsearch_7-10-0/src/service.js", }, + "ve-8-3-5": { + entryFile: "../proxmox__ve_8-3-5/bin/ve-8-3-5.js", + serviceModule: "../proxmox__ve_8-3-5/src/service.js", + }, "wangsu-label-ip": { entryFile: "../wangsu__label-ip/bin/wangsu-label-ip.js", serviceModule: "../wangsu__label-ip/src/service.js", diff --git a/services/bin/ve-8-3-5.js b/services/bin/ve-8-3-5.js new file mode 100644 index 00000000..5759e75e --- /dev/null +++ b/services/bin/ve-8-3-5.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../proxmox__ve_8-3-5/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../proxmox__ve_8-3-5/bin/ve-8-3-5.js", import.meta.url)), +}); \ No newline at end of file diff --git a/services/package.json b/services/package.json index e0d27738..a2da7b74 100644 --- a/services/package.json +++ b/services/package.json @@ -37,6 +37,7 @@ "alertmanager-0-27-0": "bin/alertmanager-0-27-0.js", "prometheus-3-0-1": "bin/prometheus-3-0-1.js", "elasticsearch-7-10-0": "bin/elasticsearch-7-10-0.js", + "ve-8-3-5": "bin/ve-8-3-5.js", "dptech-eds": "bin/dptech-eds.js", "dptech-fw-v4-6-10": "bin/dptech-fw-v4-6-10.js", "dptech-umc-ads-v5-3-29": "bin/dptech-umc-ads-v5-3-29.js", @@ -197,6 +198,7 @@ "crowdsec__security-engine", "bin/prometheus-3-0-1.js", "bin/elasticsearch-7-10-0.js", + "bin/ve-8-3-5.js", "bin/dptech-eds.js", "bin/dptech-fw-v4-6-10.js", "bin/dptech-umc-ads-v5-3-29.js", @@ -315,6 +317,7 @@ "dingtalk__group-robot", "openobserve__openobserve_v0-15-1", "elastic__elasticsearch_7-10-0", + "proxmox__ve_8-3-5", "dptech__eds", "dptech__fw_v4-6-10", "dptech__umc-ads_v5-3-29", diff --git a/services/proxmox__ve_8-3-5/README.md b/services/proxmox__ve_8-3-5/README.md new file mode 100644 index 00000000..2d618ba0 --- /dev/null +++ b/services/proxmox__ve_8-3-5/README.md @@ -0,0 +1,72 @@ +# Proxmox VE 8.3.5 + +OctoBus service package for the Proxmox VE 8.3.5 REST API (`/api2/json/`). This package exposes a small, read-only inventory surface that is useful for agents and tools that need to look up Proxmox cluster resources without depending on the full official SDK. + +## Import + +```bash +octobus service import --id ve-8-3-5 ./services/proxmox__ve_8-3-5 +``` + +## Package Layout + +- `service.json`: OctoBus service package manifest. +- `proto/proxmox_ve_8_3_5.proto`: Protobuf contract for the read-only inventory RPCs. +- `src/ve-8-3-5.js`: Runtime handler, request validation, HTTP request building, and error mapping. +- `config.schema.json`: Non-secret binding schema. +- `secret.schema.json`: Proxmox API token schema. +- `test/`: Node test coverage and mock upstream. + +## Bindings + +Configuration: + +- `baseUrl` (or `base_url`, `host`, `restBaseUrl`, `url`): Proxmox API base URL, e.g. `https://pve.example.com:8006`. +- `defaultNode` (or `default_node`, `node`): Default Proxmox node name used when a per-RPC request omits `node`. +- `allowInsecureHttp` (or `allow_insecure_http`): when `true`, allows plain HTTP base URLs (default `false`). +- `skipTlsVerify` (or `tlsInsecureSkipVerify`, `insecureSkipVerify`, `tls_skip_verify`): skip TLS certificate verification for self-signed deployments (default `false`). +- `timeoutMs` (or `timeout_ms`, `timeout`): HTTP timeout in milliseconds, default `5000`. +- `headers`: optional additional HTTP headers merged into every request. + +Secrets: + +- `tokenId` (or `token_id`): Proxmox API token identifier in the form `USER@REALM!TOKENID` (e.g. `root@pam!automation`). +- `tokenSecret` (or `token_secret`): the token secret value associated with the token ID. +- `username`, `realm`: optional metadata describing the principal and authentication realm of the token. +- `pveAuthTicket`: optional legacy PVE auth cookie, reserved for future ticket-based flows. + +## RPC Methods + +- `Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes` -> `GET /api2/json/nodes` +- `Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs` -> `GET /api2/json/nodes/{node}/qemu` +- `Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetQemuVMConfig` -> `GET /api2/json/nodes/{node}/qemu/{vmid}/config` +- `Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListLXCs` -> `GET /api2/json/nodes/{node}/lxc` +- `Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListStorage` -> `GET /api2/json/nodes/{node}/storage` +- `Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetNodeStatus` -> `GET /api2/json/nodes/{node}/status` + +## Authentication + +Each request is signed with the Proxmox API token by setting: + +``` +Authorization: PVEAPIToken=USER@REALM!TOKENID=TOKENSECRET +``` + +The header is built from `tokenId` and `tokenSecret`. Plain `PVEAuthCookie` ticket flows are not exercised by the read-only methods here. + +## Behavior + +- All RPCs are GET requests against the `/api2/json/` prefix. +- `ListNodes` does not need a `node` argument; everything else uses `req.node` (or `bindings.defaultNode`). +- `GetQemuVMConfig` requires both `node` and `vmid`. +- Responses are decoded from Proxmox's `{ "data": ... }` envelope and projected to compact proto messages. The raw upstream body and HTTP status are also returned for callers that need the full payload. +- HTTP `401` / `403` map to `PERMISSION_DENIED`; other `4xx` map to `FAILED_PRECONDITION`; `5xx` and network failures map to `UNAVAILABLE`; non-JSON or empty responses map to `UNKNOWN`. + +## Validation + +```bash +cd services +npm run validate -- --service-dir proxmox__ve_8-3-5 +npm test -- --service-dir proxmox__ve_8-3-5 --coverage +npm run pack:check +``` \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js b/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js new file mode 100644 index 00000000..d5773488 --- /dev/null +++ b/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +runServiceMain(service); \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/config.schema.json b/services/proxmox__ve_8-3-5/config.schema.json new file mode 100644 index 00000000..ce21b1a3 --- /dev/null +++ b/services/proxmox__ve_8-3-5/config.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "baseUrl": { + "type": "string", + "description": "Proxmox VE API base URL (scheme + host[:port]), for example https://pve.example.com:8006." + }, + "base_url": { + "type": "string", + "description": "Alias for baseUrl." + }, + "host": { + "type": "string", + "description": "Legacy alias for baseUrl." + }, + "restBaseUrl": { + "type": "string", + "description": "Alias for baseUrl." + }, + "url": { + "type": "string", + "description": "Alias for baseUrl." + }, + "defaultNode": { + "type": "string", + "description": "Default Proxmox node name when callers do not provide one in the request." + }, + "default_node": { + "type": "string", + "description": "Alias for defaultNode." + }, + "node": { + "type": "string", + "description": "Alias for defaultNode." + }, + "allowInsecureHttp": { + "type": "boolean", + "default": false, + "description": "Allow plain HTTP baseUrl for Proxmox deployments without TLS." + }, + "allow_insecure_http": { + "type": "boolean", + "default": false, + "description": "Alias for allowInsecureHttp." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private deployments with self-signed certs." + }, + "tlsInsecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Alias for skipTlsVerify." + }, + "insecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Alias for skipTlsVerify." + }, + "tls_skip_verify": { + "type": "boolean", + "default": false, + "description": "Alias for skipTlsVerify." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + }, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "description": "Alias for timeoutMs." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "description": "Alias for timeoutMs." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional additional HTTP headers." + } + } +} \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/package.json b/services/proxmox__ve_8-3-5/package.json new file mode 100644 index 00000000..c198a291 --- /dev/null +++ b/services/proxmox__ve_8-3-5/package.json @@ -0,0 +1,12 @@ +{ + "name": "ve-8-3-5", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "ve-8-3-5": "bin/ve-8-3-5.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto b/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto new file mode 100644 index 00000000..188dc813 --- /dev/null +++ b/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto @@ -0,0 +1,251 @@ +syntax = "proto3"; + +package Proxmox_VE_8_3_5; + +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/Proxmox_VE_8_3_5"; + +service Proxmox_VE_8_3_5 { + // GET /api2/json/nodes + rpc ListNodes(Empty) returns (ListNodesResponse); + + // GET /api2/json/nodes/{node}/qemu + rpc ListQemuVMs(ListQemuVMsRequest) returns (ListQemuVMsResponse); + + // GET /api2/json/nodes/{node}/qemu/{vmid}/config + rpc GetQemuVMConfig(GetQemuVMConfigRequest) returns (GetQemuVMConfigResponse); + + // GET /api2/json/nodes/{node}/lxc + rpc ListLXCs(ListLXCsRequest) returns (ListLXCsResponse); + + // GET /api2/json/nodes/{node}/storage + rpc ListStorage(ListStorageRequest) returns (ListStorageResponse); + + // GET /api2/json/nodes/{node}/status + rpc GetNodeStatus(GetNodeStatusRequest) returns (GetNodeStatusResponse); +} + +message Empty {} + +message NodeInfo { + string node = 1; + string status = 2; + double cpu_usage = 3; + int64 cpu_count = 4; + int64 max_cpu = 5; + int64 mem_total = 6; + int64 mem_used = 7; + int64 disk_total = 8; + int64 disk_used = 9; + int64 uptime = 10; + string level = 11; + string ip = 12; + int64 maxmem = 13; + int64 maxdisk = 14; + google.protobuf.Value raw = 15; + string ssl_fingerprint = 16; +} + +message ListNodesResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; + repeated NodeInfo nodes = 4; +} + +message QemuVMInfo { + int64 vmid = 1; + string name = 2; + string status = 3; + int64 cpus = 4; + int64 maxmem = 5; + int64 mem = 6; + int64 disk = 7; + int64 maxdisk = 8; + int64 uptime = 9; + string node = 10; + string template = 11; + google.protobuf.Value raw = 12; + double cpu = 13; + int64 disk_read = 14; + int64 disk_write = 15; + int64 memhost = 16; + int64 net_in = 17; + int64 net_out = 18; + int64 pid = 19; + string qmpstatus = 20; + string running_machine = 21; + string running_qemu = 22; + int64 serial = 23; + string lock_status = 24; + string tags = 25; + double pressure_cpu_full = 26; + double pressure_cpu_some = 27; + double pressure_io_full = 28; + double pressure_io_some = 29; + double pressure_memory_full = 30; + double pressure_memory_some = 31; +} + +message ListQemuVMsRequest { + string node = 1; +} + +message ListQemuVMsResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; + repeated QemuVMInfo vms = 4; +} + +message GetQemuVMConfigRequest { + string node = 1; + int64 vmid = 2; +} + +message GetQemuVMConfigResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; + int64 vmid = 4; + string node = 5; + string name = 6; + int64 memory = 7; + int64 cores = 8; + int64 sockets = 9; + string ostype = 10; + string scsihw = 11; + string boot = 12; + google.protobuf.Value raw_config = 13; + string description = 14; + string tags = 15; + bool template = 16; + bool onboot = 17; + bool autostart = 18; + string cpu = 19; + double cpulimit = 20; + int64 cpuunits = 21; + string bios = 22; + string machine = 23; + string arch = 24; + bool agent = 25; + string hugepages = 26; + bool keephugepages = 27; + string vmgenid = 28; + bool protection = 29; + string lock_status = 30; + int64 balloon = 31; + string digest = 32; + string hotplug = 33; + string keyboard = 34; + bool kvm = 35; +} + +message LXCInfo { + int64 vmid = 1; + string name = 2; + string status = 3; + int64 cpus = 4; + int64 maxmem = 5; + int64 mem = 6; + int64 disk = 7; + int64 maxdisk = 8; + int64 uptime = 9; + string node = 10; + string template = 11; + google.protobuf.Value raw = 12; + double cpu = 13; + int64 disk_read = 14; + int64 disk_write = 15; + int64 max_swap = 16; + int64 net_in = 17; + int64 net_out = 18; + string lock_status = 19; + string tags = 20; + double pressure_cpu_full = 21; + double pressure_cpu_some = 22; + double pressure_io_full = 23; + double pressure_io_some = 24; + double pressure_memory_full = 25; + double pressure_memory_some = 26; +} + +message ListLXCsRequest { + string node = 1; +} + +message ListLXCsResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; + repeated LXCInfo containers = 4; +} + +message StorageInfo { + string storage = 1; + string type = 2; + int64 total = 3; + int64 used = 4; + int64 avail = 5; + double used_fraction = 6; + string content = 7; + string active = 8; + string enabled = 9; + bool shared = 10; + google.protobuf.Value raw = 11; + string formats_json = 12; + bool select_existing = 13; +} + +message ListStorageRequest { + string node = 1; +} + +message ListStorageResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; + repeated StorageInfo storages = 4; +} + +message GetNodeStatusRequest { + string node = 1; +} + +message GetNodeStatusResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; + string node = 4; + string status = 5; + int64 uptime = 6; + double load_average_1m = 7; + double load_average_5m = 8; + double load_average_15m = 9; + int64 cpu_count = 10; + double cpu_usage = 11; + int64 memory_total = 12; + int64 memory_used = 13; + int64 memory_free = 14; + int64 swap_total = 15; + int64 swap_used = 16; + int64 swap_free = 17; + string kernel_version = 18; + string pve_version = 19; + google.protobuf.Value cpuinfo = 20; + string boot_info_mode = 21; + bool boot_info_secureboot = 22; + string current_kernel_sysname = 23; + string current_kernel_release = 24; + string current_kernel_version = 25; + string current_kernel_machine = 26; + int64 memory_available = 27; + int64 rootfs_total = 28; + int64 rootfs_used = 29; + int64 rootfs_free = 30; + int64 rootfs_available = 31; + int64 idle = 32; + int64 ksm_shared = 33; + double wait = 34; +} \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/secret.schema.json b/services/proxmox__ve_8-3-5/secret.schema.json new file mode 100644 index 00000000..a14ef751 --- /dev/null +++ b/services/proxmox__ve_8-3-5/secret.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "tokenId": { + "type": "string", + "description": "Proxmox API token identifier in the format USER@REALM!TOKENID (for example root@pam!automation)." + }, + "token_id": { + "type": "string", + "description": "Alias for tokenId." + }, + "tokenSecret": { + "type": "string", + "description": "Proxmox API token secret (UUID-like opaque value)." + }, + "token_secret": { + "type": "string", + "description": "Alias for tokenSecret." + }, + "username": { + "type": "string", + "description": "Optional Proxmox user principal for documentation purposes (USER@REALM portion of token_id)." + }, + "realm": { + "type": "string", + "description": "Optional Proxmox authentication realm (pam|pve|adipam|ldap|...) used by the token." + }, + "pveAuthTicket": { + "type": "string", + "description": "Optional legacy PVE authentication cookie value, kept for future ticket-based flows." + } + } +} \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/service.json b/services/proxmox__ve_8-3-5/service.json new file mode 100644 index 00000000..ca9050d7 --- /dev/null +++ b/services/proxmox__ve_8-3-5/service.json @@ -0,0 +1,49 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "ve-8-3-5", + "displayName": "Proxmox VE 8.3.5", + "description": "OctoBus package for Proxmox VE 8.3.5 read-only inventory RPCs (cluster nodes, QEMU VMs, LXC containers, storage, and node status).", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/proxmox_ve_8_3_5.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes": { + "name": "list-nodes", + "description": "List cluster nodes from the Proxmox VE 8.3.5 API." + }, + "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs": { + "name": "list-qemu-vms", + "description": "List QEMU/KVM virtual machines on a Proxmox VE 8.3.5 node." + }, + "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetQemuVMConfig": { + "name": "get-qemu-vm-config", + "description": "Get the configuration of a QEMU/KVM virtual machine on Proxmox VE 8.3.5." + }, + "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListLXCs": { + "name": "list-lxcs", + "description": "List LXC containers on a Proxmox VE 8.3.5 node." + }, + "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListStorage": { + "name": "list-storage", + "description": "List storage pools on a Proxmox VE 8.3.5 node." + }, + "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetNodeStatus": { + "name": "get-node-status", + "description": "Get status information for a Proxmox VE 8.3.5 node." + } + } + } + } +} \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/src/service.js b/services/proxmox__ve_8-3-5/src/service.js new file mode 100644 index 00000000..bab9d0c8 --- /dev/null +++ b/services/proxmox__ve_8-3-5/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './ve-8-3-5.js'; + +export { handlers } from './ve-8-3-5.js'; + +export const service = defineService({ handlers }); \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js new file mode 100644 index 00000000..6c23c9a0 --- /dev/null +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -0,0 +1,718 @@ +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +export const METHOD_LIST_NODES_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes'; +export const METHOD_LIST_QEMU_VMS_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs'; +export const METHOD_GET_QEMU_VM_CONFIG_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetQemuVMConfig'; +export const METHOD_LIST_LXCS_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListLXCs'; +export const METHOD_LIST_STORAGE_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListStorage'; +export const METHOD_GET_NODE_STATUS_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetNodeStatus'; + +export const METHOD_LIST_NODES_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes'; +export const METHOD_LIST_QEMU_VMS_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs'; +export const METHOD_GET_QEMU_VM_CONFIG_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetQemuVMConfig'; +export const METHOD_LIST_LXCS_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListLXCs'; +export const METHOD_LIST_STORAGE_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListStorage'; +export const METHOD_GET_NODE_STATUS_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetNodeStatus'; + +export const DEFAULT_TIMEOUT_MS = 5000; +export const API_PREFIX = '/api2/json'; +export const NODE_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/; +export const VMID_MAX = 9_999_999_999; + +const METHOD_PATHS = { + LIST_NODES: METHOD_LIST_NODES_PATH, + LIST_QEMU_VMS: METHOD_LIST_QEMU_VMS_PATH, + GET_QEMU_VM_CONFIG: METHOD_GET_QEMU_VM_CONFIG_PATH, + LIST_LXCS: METHOD_LIST_LXCS_PATH, + LIST_STORAGE: METHOD_LIST_STORAGE_PATH, + GET_NODE_STATUS: METHOD_GET_NODE_STATUS_PATH, +}; + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const engineError = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'object' && hasOwn(value, 'value')) return unwrapScalar(value.value); + return value; +}; + +const pickString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ''; + return String(raw).trim(); +}; + +const pickFirstString = (values = []) => { + for (const value of values) { + const str = pickString(value); + if (str) return str; + } + return ''; +}; + +const pickInt = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return 0; + const num = Number(raw); + if (!Number.isFinite(num)) return 0; + return Math.trunc(num); +}; + +const pickLong = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return 0; + const num = Number(raw); + if (!Number.isFinite(num)) return 0; + return Math.trunc(num); +}; + +const pickDouble = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return 0; + const num = Number(raw); + return Number.isFinite(num) ? num : 0; +}; + +const pickBoolean = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return undefined; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return Number.isNaN(raw) ? undefined : raw !== 0; + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off', ''].includes(normalized)) return false; + } + return undefined; +}; + +const pickFirstBoolean = (values = []) => { + for (const value of values) { + const bool = pickBoolean(value); + if (bool !== undefined) return bool; + } + return undefined; +}; + +const sanitizeHeaders = (headers) => { + const raw = unwrapScalar(headers); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + return Object.fromEntries(Object.entries(raw).filter(([key]) => key).map(([key, value]) => [key, String(unwrapScalar(value) ?? '')])); +}; + +const normalizeBaseUrl = (raw) => { + const value = pickString(raw); + if (!value) return ''; + if (!/^https?:\/\//i.test(value)) return ''; + return value.replace(/\/+$/, ''); +}; + +const isValidNodeName = (name) => NODE_NAME_RE.test(String(name ?? '').trim()); + +const isValidVmid = (value) => { + const num = Number(unwrapScalar(value)); + return Number.isInteger(num) && num > 0 && num <= VMID_MAX; +}; + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: { + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), + }, + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.req ?? ctx.request ?? {}, +}); + +const resolveBaseUrl = (bindings = {}, options = {}) => { + const candidate = pickFirstString([ + bindings.baseUrl, + bindings.base_url, + bindings.host, + bindings.restBaseUrl, + bindings.url, + ]); + const normalized = normalizeBaseUrl(candidate); + if (!normalized) { + throw engineError('INVALID_ARGUMENT', 'bindings.baseUrl is required (e.g. https://pve.example.com:8006)'); + } + const allowInsecure = pickFirstBoolean([bindings.allowInsecureHttp, bindings.allow_insecure_http]) === true; + const isHttps = /^https:\/\//i.test(normalized); + if (!isHttps && !allowInsecure && !options.allowHttp) { + throw engineError('INVALID_ARGUMENT', 'bindings.baseUrl must use https (set allowInsecureHttp to allow http)'); + } + return normalized; +}; + +const resolveToken = (bindings = {}) => { + const tokenId = pickFirstString([bindings.tokenId, bindings.token_id]); + const tokenSecret = pickFirstString([bindings.tokenSecret, bindings.token_secret]); + if (!tokenId) { + throw engineError('INVALID_ARGUMENT', 'secret.tokenId is required (format USER@REALM!TOKENID)'); + } + if (!tokenSecret) { + throw engineError('INVALID_ARGUMENT', 'secret.tokenSecret is required'); + } + if (!tokenId.includes('!')) { + throw engineError('INVALID_ARGUMENT', 'secret.tokenId must be in the form USER@REALM!TOKENID'); + } + return { tokenId, tokenSecret }; +}; + +const buildAuthHeader = (token) => { + if (!token?.tokenId || !token?.tokenSecret) { + throw engineError('INVALID_ARGUMENT', 'token is missing tokenId or tokenSecret'); + } + return `PVEAPIToken=${token.tokenId}=${token.tokenSecret}`; +}; + +const resolveTimeoutMs = (ctx = {}, fallback = DEFAULT_TIMEOUT_MS) => { + const raw = Number(unwrapScalar(ctx.limits?.timeoutMs ?? ctx.bindings?.timeoutMs ?? ctx.bindings?.timeout_ms ?? ctx.bindings?.timeout ?? fallback)); + return Number.isFinite(raw) && raw > 0 ? raw : fallback; +}; + +const shouldSkipTls = (bindings = {}) => { + const value = pickFirstBoolean([ + bindings.skipTlsVerify, + bindings.tlsInsecureSkipVerify, + bindings.insecureSkipVerify, + bindings.tls_skip_verify, + ]); + return value === true; +}; + +const buildTlsOptions = (bindings = {}) => { + if (!shouldSkipTls(bindings)) return {}; + return { + skipTlsVerify: true, + tlsInsecureSkipVerify: true, + insecureSkipVerify: true, + }; +}; + +const buildHeaders = (bindings = {}, authHeader) => ({ + ...sanitizeHeaders(bindings.headers), + Accept: 'application/json', + Authorization: authHeader, +}); + +const buildLogPrefix = (meta = {}, action) => { + const trace = []; + if (meta.instance_id || meta.instanceId) trace.push(`inst=${meta.instance_id || meta.instanceId}`); + if (meta.request_id || meta.requestId) trace.push(`req=${meta.request_id || meta.requestId}`); + return `[Proxmox_VE_8_3_5][${action}]${trace.length ? `[${trace.join(' ')}]` : ''}`; +}; + +const logFlow = (ctx = {}, action, details) => { + const prefix = buildLogPrefix(ctx.meta || {}, action); + try { + console.log(prefix, JSON.stringify(details)); + } catch { + console.log(prefix, details); + } +}; + +const encodePath = (value) => encodeURIComponent(String(unwrapScalar(value) ?? '')); + +const buildUrl = (baseUrl, segments = [], query = {}) => { + const prefix = API_PREFIX; + const cleanSegments = segments.filter((segment) => segment !== undefined && segment !== null && segment !== ''); + const path = cleanSegments.length === 0 + ? prefix + : `${prefix}/${cleanSegments.map(encodePath).join('/')}`; + const base = String(baseUrl || '').replace(/\/+$/, ''); + const queryEntries = Object.entries(query || {}) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + const queryString = queryEntries.length ? `?${queryEntries.join('&')}` : ''; + return `${base}${path}${queryString}`; +}; + +const mapHttpStatus = (status) => { + if (status === 401 || status === 403) return 'PERMISSION_DENIED'; + if (status >= 400 && status < 500) return 'FAILED_PRECONDITION'; + return 'UNAVAILABLE'; +}; + +const parseJsonBody = (text) => { + if (!text || !String(text).trim()) { + throw engineError('UNKNOWN', 'response body is empty'); + } + try { + return JSON.parse(text); + } catch { + throw engineError('UNKNOWN', 'response is not valid JSON'); + } +}; + +const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp = false } = {}) => { + const callCtx = resolveCallContext(ctx); + const bindings = callCtx.bindings || {}; + const baseUrl = resolveBaseUrl(bindings, { allowHttp }); + const token = resolveToken(bindings); + const authHeader = buildAuthHeader(token); + const timeoutMs = resolveTimeoutMs(callCtx); + const url = buildUrl(baseUrl, segments, query); + logFlow(callCtx, 'request', { method, url, segments }); + + let response; + try { + response = await fetch(url, { + method, + headers: buildHeaders(bindings, authHeader), + timeoutMs, + ...buildTlsOptions(bindings), + }); + } catch (err) { + const message = err?.cause?.message || err?.message || 'fetch failed'; + logFlow(callCtx, 'fetch:error', { url, error: message }); + throw engineError('UNAVAILABLE', `upstream fetch failed: ${message}`); + } + + const text = await response.text(); + const httpStatus = Number(response.status || 0); + logFlow(callCtx, 'fetch:response', { url, httpStatus, bodyLength: text?.length || 0 }); + + if (!response.ok) { + const code = mapHttpStatus(httpStatus); + throw engineError(code, `upstream http ${httpStatus}: ${String(text || '').slice(0, 256)}`); + } + + const json = parseJsonBody(text); + return { httpStatus, text, json }; +}; + +const requireNodeName = (req = {}, bindings = {}, methodLabel) => { + const fromReq = pickFirstString([req.node, req.nodeName, req.name]); + const node = fromReq || pickFirstString([bindings.defaultNode, bindings.default_node, bindings.node]); + if (!node) { + throw engineError('INVALID_ARGUMENT', `${methodLabel}: node is required (request.node or bindings.defaultNode)`); + } + if (!isValidNodeName(node)) { + throw engineError('INVALID_ARGUMENT', `${methodLabel}: node name "${node}" is not a valid Proxmox node name`); + } + return node; +}; + +const requireVmid = (req = {}, methodLabel) => { + const raw = unwrapScalar(req.vmid ?? req.vmId ?? req.VMID); + if (raw === undefined || raw === null || raw === '') { + throw engineError('INVALID_ARGUMENT', `${methodLabel}: vmid is required`); + } + const num = Number(raw); + if (!Number.isInteger(num) || num <= 0 || num > VMID_MAX) { + throw engineError('INVALID_ARGUMENT', `${methodLabel}: vmid must be a positive integer`); + } + return num; +}; + +const resolveVmidString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return 0; + const num = Number(raw); + if (!Number.isInteger(num) || num <= 0) return 0; + return num; +}; + +const valueOrZeroLong = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return 0; + const num = Number(raw); + if (!Number.isFinite(num)) return 0; + return Math.trunc(num); +}; + +const valueOrZeroDouble = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return 0; + const num = Number(raw); + return Number.isFinite(num) ? num : 0; +}; + +const wrapRawBody = (text) => String(text ?? ''); + +const asJsonValue = (value) => value === undefined ? null : value; + +const buildNodeInfo = (entry) => { + const raw = entry && typeof entry === 'object' ? entry : {}; + return { + node: pickString(raw.node ?? raw.name), + status: pickString(raw.status), + cpu_usage: pickDouble(raw.cpu ?? raw.cpu_usage), + cpu_count: pickLong(raw.cpu_count ?? raw.maxcpu ?? raw.cpus), + max_cpu: pickLong(raw.maxcpu ?? raw.cpu_count ?? raw.cpus), + mem_total: pickLong(raw.maxmem ?? raw.memory_total), + mem_used: pickLong(raw.mem ?? raw.memory_used), + disk_total: pickLong(raw.maxdisk ?? raw.disk_total), + disk_used: pickLong(raw.disk ?? raw.disk_used), + uptime: pickLong(raw.uptime), + level: pickString(raw.level), + ip: pickString(raw.ip ?? raw.ip_address ?? raw.addr), + maxmem: pickLong(raw.maxmem), + maxdisk: pickLong(raw.maxdisk), + raw: asJsonValue(raw), + ssl_fingerprint: pickString(raw.ssl_fingerprint), + }; +}; + +const buildQemuVMInfo = (entry) => { + const raw = entry && typeof entry === 'object' ? entry : {}; + return { + vmid: resolveVmidString(raw.vmid), + name: pickString(raw.name), + status: pickString(raw.status), + cpus: valueOrZeroLong(raw.cpus), + maxmem: valueOrZeroLong(raw.maxmem), + mem: valueOrZeroLong(raw.mem), + disk: valueOrZeroLong(raw.disk), + maxdisk: valueOrZeroLong(raw.maxdisk), + uptime: valueOrZeroLong(raw.uptime), + node: pickString(raw.node), + template: pickString(raw.template), + raw: asJsonValue(raw), + cpu: valueOrZeroDouble(raw.cpu), + disk_read: valueOrZeroLong(raw.diskread), + disk_write: valueOrZeroLong(raw.diskwrite), + memhost: valueOrZeroLong(raw.memhost), + net_in: valueOrZeroLong(raw.netin), + net_out: valueOrZeroLong(raw.netout), + pid: valueOrZeroLong(raw.pid), + qmpstatus: pickString(raw.qmpstatus), + running_machine: pickString(raw['running-machine']), + running_qemu: pickString(raw['running-qemu']), + serial: valueOrZeroLong(raw.serial), + lock_status: pickString(raw.lock), + tags: pickString(raw.tags), + pressure_cpu_full: valueOrZeroDouble(raw.pressurecpufull), + pressure_cpu_some: valueOrZeroDouble(raw.pressurecpusome), + pressure_io_full: valueOrZeroDouble(raw.pressureiofull), + pressure_io_some: valueOrZeroDouble(raw.pressureiosome), + pressure_memory_full: valueOrZeroDouble(raw.pressurememoryfull), + pressure_memory_some: valueOrZeroDouble(raw.pressurememorysome), + }; +}; + +const buildLXCInfo = (entry) => { + const raw = entry && typeof entry === 'object' ? entry : {}; + return { + vmid: resolveVmidString(raw.vmid), + name: pickString(raw.name), + status: pickString(raw.status), + cpus: valueOrZeroLong(raw.cpus), + maxmem: valueOrZeroLong(raw.maxmem), + mem: valueOrZeroLong(raw.mem), + disk: valueOrZeroLong(raw.disk), + maxdisk: valueOrZeroLong(raw.maxdisk), + uptime: valueOrZeroLong(raw.uptime), + node: pickString(raw.node), + template: pickString(raw.template), + raw: asJsonValue(raw), + cpu: valueOrZeroDouble(raw.cpu), + disk_read: valueOrZeroLong(raw.diskread), + disk_write: valueOrZeroLong(raw.diskwrite), + max_swap: valueOrZeroLong(raw.maxswap), + net_in: valueOrZeroLong(raw.netin), + net_out: valueOrZeroLong(raw.netout), + lock_status: pickString(raw.lock), + tags: pickString(raw.tags), + pressure_cpu_full: valueOrZeroDouble(raw.pressurecpufull), + pressure_cpu_some: valueOrZeroDouble(raw.pressurecpusome), + pressure_io_full: valueOrZeroDouble(raw.pressureiofull), + pressure_io_some: valueOrZeroDouble(raw.pressureiosome), + pressure_memory_full: valueOrZeroDouble(raw.pressurememoryfull), + pressure_memory_some: valueOrZeroDouble(raw.pressurememorysome), + }; +}; + +const buildStorageInfo = (entry) => { + const raw = entry && typeof entry === 'object' ? entry : {}; + const formats = unwrapScalar(raw.formats); + let formatsJson = ''; + if (formats !== undefined && formats !== null && formats !== '') { + try { formatsJson = typeof formats === 'string' ? formats : JSON.stringify(formats); } + catch { formatsJson = ''; } + } + return { + storage: pickString(raw.storage ?? raw.name), + type: pickString(raw.type), + total: valueOrZeroLong(raw.total), + used: valueOrZeroLong(raw.used), + avail: valueOrZeroLong(raw.avail), + used_fraction: valueOrZeroDouble(raw.used_fraction ?? (raw.total ? Number(raw.used) / Number(raw.total) : 0)), + content: pickString(raw.content), + active: pickString(raw.active), + enabled: pickString(raw.enabled), + shared: pickBoolean(raw.shared) ?? false, + raw: asJsonValue(raw), + formats_json: formatsJson, + select_existing: pickBoolean(raw.select_existing) ?? false, + }; +}; + +const buildNodeStatus = (raw, node) => { + const data = raw && typeof raw === 'object' ? raw : {}; + const loadavg = Array.isArray(data.loadavg) ? data.loadavg : []; + const cpuinfo = data.cpuinfo && typeof data.cpuinfo === 'object' ? data.cpuinfo : null; + const bootInfo = data['boot-info'] && typeof data['boot-info'] === 'object' ? data['boot-info'] : null; + const currentKernel = data['current-kernel'] && typeof data['current-kernel'] === 'object' ? data['current-kernel'] : null; + const memory = data.memory && typeof data.memory === 'object' ? data.memory : null; + const rootfs = data.rootfs && typeof data.rootfs === 'object' ? data.rootfs : null; + const ksm = data.ksm && typeof data.ksm === 'object' ? data.ksm : null; + return { + node: pickString(data.node) || node, + status: pickString(data.status), + uptime: valueOrZeroLong(data.uptime), + load_average_1m: valueOrZeroDouble(loadavg[0]), + load_average_5m: valueOrZeroDouble(loadavg[1]), + load_average_15m: valueOrZeroDouble(loadavg[2]), + cpu_count: valueOrZeroLong(data.cpu_count), + cpu_usage: valueOrZeroDouble(data.cpu_usage), + memory_total: valueOrZeroLong(memory?.total), + memory_used: valueOrZeroLong(memory?.used), + memory_free: valueOrZeroLong(memory?.free), + swap_total: valueOrZeroLong(data.swap?.total), + swap_used: valueOrZeroLong(data.swap?.used), + swap_free: valueOrZeroLong(data.swap?.free), + kernel_version: pickString(data.kversion ?? data.kernel), + pve_version: pickString(data.pveversion), + cpuinfo: asJsonValue(cpuinfo), + boot_info_mode: pickString(bootInfo?.mode), + boot_info_secureboot: pickBoolean(bootInfo?.secureboot) ?? false, + current_kernel_sysname: pickString(currentKernel?.sysname), + current_kernel_release: pickString(currentKernel?.release), + current_kernel_version: pickString(currentKernel?.version), + current_kernel_machine: pickString(currentKernel?.machine), + memory_available: valueOrZeroLong(memory?.available), + rootfs_total: valueOrZeroLong(rootfs?.total), + rootfs_used: valueOrZeroLong(rootfs?.used), + rootfs_free: valueOrZeroLong(rootfs?.free), + rootfs_available: valueOrZeroLong(rootfs?.avail), + idle: valueOrZeroLong(data.idle), + ksm_shared: valueOrZeroLong(ksm?.shared), + wait: valueOrZeroDouble(data.wait), + }; +}; + +const buildQemuVMConfig = (raw, node, vmid) => { + const data = raw && typeof raw === 'object' ? raw : {}; + return { + vmid: resolveVmidString(data.vmid) || vmid, + node: pickString(data.node) || node, + name: pickString(data.name), + memory: valueOrZeroLong(data.memory), + cores: valueOrZeroLong(data.cores), + sockets: valueOrZeroLong(data.sockets), + ostype: pickString(data.ostype), + scsihw: pickString(data.scsihw), + boot: pickString(data.boot), + raw_config: asJsonValue(data), + description: pickString(data.description), + tags: pickString(data.tags), + template: pickBoolean(data.template) ?? false, + onboot: pickBoolean(data.onboot) ?? false, + autostart: pickBoolean(data.autostart) ?? false, + cpu: pickString(data.cpu), + cpulimit: valueOrZeroDouble(data.cpulimit), + cpuunits: valueOrZeroLong(data.cpuunits), + bios: pickString(data.bios), + machine: pickString(data.machine), + arch: pickString(data.arch), + agent: pickBoolean(data.agent) ?? false, + hugepages: pickString(data.hugepages), + keephugepages: pickBoolean(data.keephugepages) ?? false, + vmgenid: pickString(data.vmgenid), + protection: pickBoolean(data.protection) ?? false, + lock_status: pickString(data.lock), + balloon: valueOrZeroLong(data.balloon), + digest: pickString(data.digest), + hotplug: pickString(data.hotplug), + keyboard: pickString(data.keyboard), + kvm: pickBoolean(data.kvm) ?? false, + }; +}; + +const extractData = (payload) => { + if (payload === null || payload === undefined) return null; + if (Array.isArray(payload)) return payload; + if (typeof payload === 'object' && hasOwn(payload, 'data')) return payload.data; + return payload; +}; + +const arrayOrEmpty = (value) => Array.isArray(value) ? value : []; + +const handleListNodes = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const { httpStatus, text, json } = await proxmoxRequest(callCtx, ['nodes'], { method: 'GET' }); + const data = extractData(json); + const nodes = arrayOrEmpty(data).map(buildNodeInfo); + return { + http_status: httpStatus, + raw_body: wrapRawBody(text), + raw_json: asJsonValue(json), + nodes, + }; +}; + +const handleListQemuVMs = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const node = requireNodeName(req, callCtx.bindings || {}, 'ListQemuVMs'); + const { httpStatus, text, json } = await proxmoxRequest(callCtx, ['nodes', node, 'qemu'], { method: 'GET' }); + const data = extractData(json); + const vms = arrayOrEmpty(data).map(buildQemuVMInfo); + return { + http_status: httpStatus, + raw_body: wrapRawBody(text), + raw_json: asJsonValue(json), + vms, + }; +}; + +const handleGetQemuVMConfig = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const node = requireNodeName(req, callCtx.bindings || {}, 'GetQemuVMConfig'); + const vmid = requireVmid(req, 'GetQemuVMConfig'); + const { httpStatus, text, json } = await proxmoxRequest(callCtx, ['nodes', node, 'qemu', vmid, 'config'], { method: 'GET' }); + const data = extractData(json); + return { + http_status: httpStatus, + raw_body: wrapRawBody(text), + raw_json: asJsonValue(json), + ...buildQemuVMConfig(data, node, vmid), + }; +}; + +const handleListLXCs = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const node = requireNodeName(req, callCtx.bindings || {}, 'ListLXCs'); + const { httpStatus, text, json } = await proxmoxRequest(callCtx, ['nodes', node, 'lxc'], { method: 'GET' }); + const data = extractData(json); + const containers = arrayOrEmpty(data).map(buildLXCInfo); + return { + http_status: httpStatus, + raw_body: wrapRawBody(text), + raw_json: asJsonValue(json), + containers, + }; +}; + +const handleListStorage = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const node = requireNodeName(req, callCtx.bindings || {}, 'ListStorage'); + const { httpStatus, text, json } = await proxmoxRequest(callCtx, ['nodes', node, 'storage'], { method: 'GET' }); + const data = extractData(json); + const storages = arrayOrEmpty(data).map(buildStorageInfo); + return { + http_status: httpStatus, + raw_body: wrapRawBody(text), + raw_json: asJsonValue(json), + storages, + }; +}; + +const handleGetNodeStatus = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const node = requireNodeName(req, callCtx.bindings || {}, 'GetNodeStatus'); + const { httpStatus, text, json } = await proxmoxRequest(callCtx, ['nodes', node, 'status'], { method: 'GET' }); + const data = extractData(json); + return { + http_status: httpStatus, + raw_body: wrapRawBody(text), + raw_json: asJsonValue(json), + ...buildNodeStatus(data, node), + }; +}; + +export function rpcdef(ctx = {}) { + const callCtx = resolveCallContext(ctx); + return { + [METHOD_LIST_NODES_PATH]: async (req) => handleListNodes(req ?? callCtx.req ?? {}, callCtx), + [METHOD_LIST_QEMU_VMS_PATH]: async (req) => handleListQemuVMs(req ?? callCtx.req ?? {}, callCtx), + [METHOD_GET_QEMU_VM_CONFIG_PATH]: async (req) => handleGetQemuVMConfig(req ?? callCtx.req ?? {}, callCtx), + [METHOD_LIST_LXCS_PATH]: async (req) => handleListLXCs(req ?? callCtx.req ?? {}, callCtx), + [METHOD_LIST_STORAGE_PATH]: async (req) => handleListStorage(req ?? callCtx.req ?? {}, callCtx), + [METHOD_GET_NODE_STATUS_PATH]: async (req) => handleGetNodeStatus(req ?? callCtx.req ?? {}, callCtx), + }; +} + +export const handlers = { + [METHOD_LIST_NODES_FULL]: (req, ctx = {}) => handleListNodes(req, ctx), + [METHOD_LIST_QEMU_VMS_FULL]: (req, ctx = {}) => handleListQemuVMs(req, ctx), + [METHOD_GET_QEMU_VM_CONFIG_FULL]: (req, ctx = {}) => handleGetQemuVMConfig(req, ctx), + [METHOD_LIST_LXCS_FULL]: (req, ctx = {}) => handleListLXCs(req, ctx), + [METHOD_LIST_STORAGE_FULL]: (req, ctx = {}) => handleListStorage(req, ctx), + [METHOD_GET_NODE_STATUS_FULL]: (req, ctx = {}) => handleGetNodeStatus(req, ctx), +}; + +export const _test = { + API_PREFIX, + DEFAULT_TIMEOUT_MS, + METHOD_PATHS, + VMID_MAX, + NODE_NAME_RE, + arrayOrEmpty, + asJsonValue, + buildAuthHeader, + buildHeaders, + buildLXCInfo, + buildLogPrefix, + buildNodeInfo, + buildNodeStatus, + buildQemuVMConfig, + buildQemuVMInfo, + buildStorageInfo, + buildTlsOptions, + buildUrl, + engineError, + extractData, + grpcCodeFor, + handleGetNodeStatus, + handleGetQemuVMConfig, + handleListLXCs, + handleListNodes, + handleListQemuVMs, + handleListStorage, + hasOwn, + isValidNodeName, + isValidVmid, + logFlow, + mapHttpStatus, + normalizeBaseUrl, + parseJsonBody, + pickBoolean, + pickDouble, + pickFirstBoolean, + pickFirstString, + pickInt, + pickLong, + pickString, + proxmoxRequest, + requireNodeName, + requireVmid, + resolveBaseUrl, + resolveCallContext, + resolveTimeoutMs, + resolveToken, + resolveVmidString, + sanitizeHeaders, + shouldSkipTls, + unwrapScalar, + valueOrZeroDouble, + valueOrZeroLong, + wrapRawBody, +}; \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/test/mock_upstream.js b/services/proxmox__ve_8-3-5/test/mock_upstream.js new file mode 100644 index 00000000..616b7593 --- /dev/null +++ b/services/proxmox__ve_8-3-5/test/mock_upstream.js @@ -0,0 +1,249 @@ +import http from 'node:http'; + +export const TOKEN_ID = 'root@pam!automation'; +export const TOKEN_SECRET = '11111111-2222-3333-4444-555555555555'; +export const DEFAULT_NODE = 'pve-node-1'; + +const VALID_TOKEN_HEADER = `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`; + +const send = (res, status, body, headers = {}) => { + const payload = typeof body === 'string' ? body : JSON.stringify(body); + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers }); + res.end(payload); +}; + +const notFound = (res, message = 'not found') => { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end(message); +}; + +const parseVmid = (value) => { + const num = Number(value); + if (!Number.isInteger(num) || num <= 0) return null; + return num; +}; + +export function createMockServer({ + expectedTokenId = TOKEN_ID, + expectedTokenSecret = TOKEN_SECRET, +} = {}) { + const requests = []; + const expectedAuth = `PVEAPIToken=${expectedTokenId}=${expectedTokenSecret}`; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + requests.push({ method: req.method, path: url.pathname, headers: req.headers }); + + if (req.method !== 'GET') { + send(res, 405, { errors: [{ msg: 'method not allowed' }] }); + return; + } + + const auth = String(req.headers.authorization || ''); + if (!auth) { + send(res, 401, { errors: [{ msg: 'missing Authorization header' }] }); + return; + } + if (auth !== expectedAuth) { + send(res, 403, { errors: [{ msg: 'invalid PVEAPIToken' }] }); + return; + } + + if (url.pathname === '/api2/json/nodes') { + send(res, 200, { + data: [ + { + node: 'pve-node-1', + status: 'online', + level: 'c', + ip: '10.0.0.11', + cpu: 0.12, + cpu_count: 16, + maxcpu: 16, + mem: 8589934592, + maxmem: 34359738368, + disk: 107374182400, + maxdisk: 536870912000, + uptime: 9000, + }, + { + node: 'pve-node-2', + status: 'offline', + level: '', + ip: '10.0.0.12', + cpu: 0, + cpu_count: 8, + maxcpu: 8, + mem: 0, + maxmem: 16777216000, + disk: 0, + maxdisk: 268435456000, + uptime: 0, + }, + ], + }); + return; + } + + const qemuListMatch = /^\/api2\/json\/nodes\/([^/]+)\/qemu$/.exec(url.pathname); + if (qemuListMatch) { + const node = decodeURIComponent(qemuListMatch[1]); + if (node !== 'pve-node-1' && node !== 'pve-node-2') { + send(res, 500, { errors: [{ msg: `node "${node}" not found` }] }); + return; + } + send(res, 200, { + data: [ + { + vmid: 100, + name: 'web-1', + status: 'running', + cpus: 2, + maxmem: 2147483648, + mem: 1073741824, + disk: 10737418240, + maxdisk: 21474836480, + uptime: 12345, + node, + template: 0, + }, + { + vmid: 101, + name: 'db-1', + status: 'stopped', + cpus: 4, + maxmem: 4294967296, + mem: 0, + disk: 21474836480, + maxdisk: 32212254720, + uptime: 0, + node, + template: 0, + }, + ], + }); + return; + } + + const qemuConfigMatch = /^\/api2\/json\/nodes\/([^/]+)\/qemu\/([^/]+)\/config$/.exec(url.pathname); + if (qemuConfigMatch) { + const node = decodeURIComponent(qemuConfigMatch[1]); + const vmid = parseVmid(decodeURIComponent(qemuConfigMatch[2])); + if (!vmid) { + send(res, 400, { errors: [{ msg: 'invalid vmid' }] }); + return; + } + send(res, 200, { + data: { + vmid, + name: `vm-${vmid}`, + memory: 2048, + cores: 2, + sockets: 1, + ostype: 'l26', + scsihw: 'virtio-scsi-pci', + boot: 'order=scsi0', + net0: 'virtio=00:11:22:33:44:55,bridge=vmbr0', + }, + }); + return; + } + + const lxcListMatch = /^\/api2\/json\/nodes\/([^/]+)\/lxc$/.exec(url.pathname); + if (lxcListMatch) { + const node = decodeURIComponent(lxcListMatch[1]); + send(res, 200, { + data: [ + { + vmid: 200, + name: 'lxc-web', + status: 'running', + cpus: 1, + maxmem: 536870912, + mem: 268435456, + disk: 4294967296, + maxdisk: 8589934592, + uptime: 60, + node, + template: 0, + }, + ], + }); + return; + } + + const storageListMatch = /^\/api2\/json\/nodes\/([^/]+)\/storage$/.exec(url.pathname); + if (storageListMatch) { + const node = decodeURIComponent(storageListMatch[1]); + send(res, 200, { + data: [ + { + storage: 'local', + type: 'dir', + total: 107374182400, + used: 21474836480, + avail: 85899345920, + used_fraction: 0.2, + content: 'iso,vztmpl,backup', + active: '1', + enabled: '1', + shared: false, + }, + { + storage: 'nfs-pool', + type: 'nfs', + total: 1099511627776, + used: 549755813888, + avail: 549755813888, + used_fraction: 0.5, + content: 'images,rootdir', + active: '1', + enabled: '1', + shared: true, + }, + ], + }); + return; + } + + const statusMatch = /^\/api2\/json\/nodes\/([^/]+)\/status$/.exec(url.pathname); + if (statusMatch) { + const node = decodeURIComponent(statusMatch[1]); + send(res, 200, { + data: { + node, + status: 'online', + uptime: 12345, + loadavg: [0.12, 0.34, 0.56], + cpu_count: 16, + cpu_usage: 0.18, + memory: { total: 34359738368, used: 17179869184, free: 17179869184 }, + swap: { total: 8589934592, used: 0, free: 8589934592 }, + kversion: 'Linux 6.8.4-2-pve', + pveversion: 'pve-manager/8.3.5/4562d8152094b115', + cpuinfo: { model: 'Intel(R) Xeon(R) CPU', cores: 16, mhz: 3200 }, + }, + }); + return; + } + + notFound(res, `unhandled path: ${url.pathname}`); + }); + + return { + requests, + validAuthHeader: VALID_TOKEN_HEADER, + async start() { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + return { + baseUrl: `http://${address.address}:${address.port}`, + origin: `http://${address.address}:${address.port}`, + port: address.port, + }; + }, + async close() { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + }, + }; +} \ No newline at end of file diff --git a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js new file mode 100644 index 00000000..0823d883 --- /dev/null +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -0,0 +1,685 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { + METHOD_GET_NODE_STATUS_FULL, + METHOD_GET_NODE_STATUS_PATH, + METHOD_GET_QEMU_VM_CONFIG_FULL, + METHOD_GET_QEMU_VM_CONFIG_PATH, + METHOD_LIST_LXCS_FULL, + METHOD_LIST_LXCS_PATH, + METHOD_LIST_NODES_FULL, + METHOD_LIST_NODES_PATH, + METHOD_LIST_QEMU_VMS_FULL, + METHOD_LIST_QEMU_VMS_PATH, + METHOD_LIST_STORAGE_FULL, + METHOD_LIST_STORAGE_PATH, + _test, + handlers, + rpcdef, +} from '../src/ve-8-3-5.js'; +import { service } from '../src/service.js'; +import { + DEFAULT_NODE, + TOKEN_ID, + TOKEN_SECRET, + createMockServer, +} from './mock_upstream.js'; + +const originalFetch = globalThis.fetch; +const originalConsoleLog = console.log; + +const responseOf = (status, body) => ({ + ok: status >= 200 && status < 300, + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), +}); + +const setFetch = (impl) => { + globalThis.fetch = impl; +}; + +const buildCtx = (overrides = {}) => ({ + config: { + baseUrl: 'https://pve.example.com:8006', + defaultNode: DEFAULT_NODE, + timeoutMs: 4000, + ...(overrides.config || {}), + }, + secret: { + tokenId: TOKEN_ID, + tokenSecret: TOKEN_SECRET, + ...(overrides.secret || {}), + }, + bindings: overrides.bindings || {}, + limits: { timeoutMs: 4000, ...(overrides.limits || {}) }, + meta: { instance_id: 'inst-1', request_id: 'req-1', ...(overrides.meta || {}) }, + req: overrides.req || {}, +}); + +const expectGrpcError = async (fn, legacyCode, checker = () => {}) => { + let caught; + try { + await fn(); + } catch (err) { + caught = err; + } + assert.ok(caught, 'expected function to reject'); + assert.ok(caught instanceof GrpcError, `expected GrpcError, got ${caught?.constructor?.name}`); + assert.equal(caught.legacyCode, legacyCode); + const codes = { + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, + }; + assert.equal(caught.code, codes[legacyCode]); + assert.match(caught.message, new RegExp(`^${legacyCode}:`)); + checker(caught); +}; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalConsoleLog; +}); + +test('service exports handlers and rpcdef path handlers', () => { + assert.equal(typeof service, 'object'); + for (const key of [ + METHOD_LIST_NODES_FULL, + METHOD_LIST_QEMU_VMS_FULL, + METHOD_GET_QEMU_VM_CONFIG_FULL, + METHOD_LIST_LXCS_FULL, + METHOD_LIST_STORAGE_FULL, + METHOD_GET_NODE_STATUS_FULL, + ]) { + assert.equal(typeof handlers[key], 'function', `handler for ${key} should be a function`); + } + const defs = rpcdef(buildCtx()); + for (const key of [ + METHOD_LIST_NODES_PATH, + METHOD_LIST_QEMU_VMS_PATH, + METHOD_GET_QEMU_VM_CONFIG_PATH, + METHOD_LIST_LXCS_PATH, + METHOD_LIST_STORAGE_PATH, + METHOD_GET_NODE_STATUS_PATH, + ]) { + assert.equal(typeof defs[key], 'function', `rpcdef for ${key} should be a function`); + } +}); + +test('ListNodes happy path issues GET to /api2/json/nodes', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return responseOf(200, { + data: [ + { node: 'pve-a', status: 'online', cpu: 0.1, cpu_count: 8, maxmem: 4096, mem: 2048, uptime: 60 }, + ], + }); + }); + + const res = await handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()); + assert.equal(captured.init.method, 'GET'); + assert.equal(captured.url, 'https://pve.example.com:8006/api2/json/nodes'); + assert.equal(captured.init.headers.Authorization, `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`); + assert.equal(captured.init.headers.Accept, 'application/json'); + assert.equal(captured.init.timeoutMs, 4000); + assert.equal(res.http_status, 200); + assert.equal(res.nodes.length, 1); + assert.equal(res.nodes[0].node, 'pve-a'); + assert.equal(res.nodes[0].status, 'online'); + assert.equal(res.nodes[0].cpu_count, 8); + assert.match(res.raw_body, /pve-a/); +}); + +test('ListNodes missing baseUrl returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx({ config: { baseUrl: '' } })), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /baseUrl/), + ); +}); + +test('ListNodes missing token returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx({ secret: { tokenId: '', tokenSecret: '' } })), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /tokenId/), + ); +}); + +test('ListNodes http 401 maps to PERMISSION_DENIED', async () => { + setFetch(async () => responseOf(401, 'no auth')); + await expectGrpcError(() => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), 'PERMISSION_DENIED'); +}); + +test('ListQemuVMs builds correct URL with node and Authorization header', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return responseOf(200, { + data: [ + { vmid: 100, name: 'vm-100', status: 'running', cpus: 2, maxmem: 1024, mem: 256 }, + ], + }); + }); + + const res = await handlers[METHOD_LIST_QEMU_VMS_FULL]({ node: 'pve-node-1' }, buildCtx()); + assert.equal(captured.url, 'https://pve.example.com:8006/api2/json/nodes/pve-node-1/qemu'); + assert.equal(captured.init.headers.Authorization, `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`); + assert.equal(res.vms.length, 1); + assert.equal(res.vms[0].vmid, 100); + assert.equal(res.vms[0].name, 'vm-100'); +}); + +test('ListQemuVMs falls back to bindings.defaultNode when request omits node', async () => { + let url; + setFetch(async (u) => { + url = String(u); + return responseOf(200, { data: [] }); + }); + await handlers[METHOD_LIST_QEMU_VMS_FULL]({}, buildCtx()); + assert.equal(url, `https://pve.example.com:8006/api2/json/nodes/${DEFAULT_NODE}/qemu`); +}); + +test('ListQemuVMs missing node returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_LIST_QEMU_VMS_FULL]({}, buildCtx({ config: { defaultNode: '' } })), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /node is required/), + ); +}); + +test('ListQemuVMs http 500 maps to UNAVAILABLE', async () => { + setFetch(async () => responseOf(500, 'broken')); + await expectGrpcError(() => handlers[METHOD_LIST_QEMU_VMS_FULL]({ node: 'pve-node-1' }, buildCtx()), 'UNAVAILABLE'); +}); + +test('GetQemuVMConfig happy path includes vmid in URL and config in response', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return responseOf(200, { + data: { + vmid: 100, + name: 'web-1', + memory: 4096, + cores: 4, + sockets: 1, + ostype: 'l26', + scsihw: 'virtio-scsi-pci', + boot: 'order=scsi0', + }, + }); + }); + + const res = await handlers[METHOD_GET_QEMU_VM_CONFIG_FULL]({ node: 'pve-node-1', vmid: 100 }, buildCtx()); + assert.equal(captured.url, 'https://pve.example.com:8006/api2/json/nodes/pve-node-1/qemu/100/config'); + assert.equal(captured.init.method, 'GET'); + assert.equal(res.vmid, 100); + assert.equal(res.node, 'pve-node-1'); + assert.equal(res.name, 'web-1'); + assert.equal(res.memory, 4096); + assert.equal(res.cores, 4); + assert.equal(res.sockets, 1); + assert.equal(res.ostype, 'l26'); +}); + +test('GetQemuVMConfig missing vmid returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_GET_QEMU_VM_CONFIG_FULL]({ node: 'pve-node-1' }, buildCtx()), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /vmid is required/), + ); +}); + +test('GetQemuVMConfig invalid vmid returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_GET_QEMU_VM_CONFIG_FULL]({ node: 'pve-node-1', vmid: 'abc' }, buildCtx()), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /vmid/), + ); +}); + +test('GetQemuVMConfig upstream 404 maps to FAILED_PRECONDITION', async () => { + setFetch(async () => responseOf(404, 'no such vm')); + await expectGrpcError( + () => handlers[METHOD_GET_QEMU_VM_CONFIG_FULL]({ node: 'pve-node-1', vmid: 9999 }, buildCtx()), + 'FAILED_PRECONDITION', + ); +}); + +test('ListLXCs happy path decodes container list', async () => { + setFetch(async () => responseOf(200, { + data: [ + { vmid: 200, name: 'lxc-web', status: 'running', cpus: 1, maxmem: 512, mem: 128 }, + ], + })); + const res = await handlers[METHOD_LIST_LXCS_FULL]({ node: 'pve-node-2' }, buildCtx()); + assert.equal(res.containers.length, 1); + assert.equal(res.containers[0].vmid, 200); + assert.equal(res.containers[0].name, 'lxc-web'); + assert.equal(res.http_status, 200); +}); + +test('ListLXCs missing node returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_LIST_LXCS_FULL]({}, buildCtx({ config: { defaultNode: '' } })), + 'INVALID_ARGUMENT', + ); +}); + +test('ListLXCs malformed node name returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_LIST_LXCS_FULL]({ node: 'has space' }, buildCtx({ config: { defaultNode: '' } })), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /node name/), + ); +}); + +test('ListStorage happy path decodes storage pool list', async () => { + setFetch(async () => responseOf(200, { + data: [ + { storage: 'local', type: 'dir', total: 1024, used: 256, avail: 768, used_fraction: 0.25, content: 'iso,vztmpl', active: '1', enabled: '1', shared: false }, + { storage: 'nfs', type: 'nfs', total: 2048, used: 0, avail: 2048, used_fraction: 0, content: 'images', active: '1', enabled: '1', shared: true }, + ], + })); + const res = await handlers[METHOD_LIST_STORAGE_FULL]({ node: 'pve-node-1' }, buildCtx()); + assert.equal(res.storages.length, 2); + assert.equal(res.storages[0].storage, 'local'); + assert.equal(res.storages[0].type, 'dir'); + assert.equal(res.storages[0].shared, false); + assert.equal(res.storages[1].storage, 'nfs'); + assert.equal(res.storages[1].shared, true); +}); + +test('ListStorage missing node returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_LIST_STORAGE_FULL]({}, buildCtx({ config: { defaultNode: '' } })), + 'INVALID_ARGUMENT', + ); +}); + +test('ListStorage http 403 maps to PERMISSION_DENIED', async () => { + setFetch(async () => responseOf(403, 'forbidden')); + await expectGrpcError(() => handlers[METHOD_LIST_STORAGE_FULL]({ node: 'pve-node-1' }, buildCtx()), 'PERMISSION_DENIED'); +}); + +test('GetNodeStatus happy path decodes loadavg and memory', async () => { + setFetch(async () => responseOf(200, { + data: { + node: 'pve-node-1', + status: 'online', + uptime: 1234, + loadavg: [0.1, 0.2, 0.3], + cpu_count: 8, + cpu_usage: 0.25, + memory: { total: 1000, used: 250, free: 750 }, + swap: { total: 500, used: 10, free: 490 }, + kversion: 'Linux 6.8', + pveversion: 'pve-manager/8.3.5/test', + cpuinfo: { model: 'test-cpu' }, + }, + })); + const res = await handlers[METHOD_GET_NODE_STATUS_FULL]({ node: 'pve-node-1' }, buildCtx()); + assert.equal(res.node, 'pve-node-1'); + assert.equal(res.status, 'online'); + assert.equal(res.uptime, 1234); + assert.equal(res.load_average_1m, 0.1); + assert.equal(res.load_average_5m, 0.2); + assert.equal(res.load_average_15m, 0.3); + assert.equal(res.cpu_count, 8); + assert.equal(res.cpu_usage, 0.25); + assert.equal(res.memory_total, 1000); + assert.equal(res.memory_used, 250); + assert.equal(res.memory_free, 750); + assert.equal(res.swap_total, 500); + assert.equal(res.swap_used, 10); + assert.equal(res.kernel_version, 'Linux 6.8'); + assert.equal(res.pve_version, 'pve-manager/8.3.5/test'); + assert.deepEqual(res.cpuinfo, { model: 'test-cpu' }); +}); + +test('GetNodeStatus missing node returns INVALID_ARGUMENT', async () => { + await expectGrpcError( + () => handlers[METHOD_GET_NODE_STATUS_FULL]({}, buildCtx({ config: { defaultNode: '' } })), + 'INVALID_ARGUMENT', + ); +}); + +test('GetNodeStatus non-JSON response maps to UNKNOWN', async () => { + setFetch(async () => responseOf(200, 'not json')); + await expectGrpcError(() => handlers[METHOD_GET_NODE_STATUS_FULL]({ node: 'pve-node-1' }, buildCtx()), 'UNKNOWN'); +}); + +test('mock upstream supports all RPCs end-to-end', async () => { + const mock = createMockServer(); + const { baseUrl } = await mock.start(); + try { + const ctx = buildCtx({ config: { baseUrl, allowInsecureHttp: true }, bindings: { skipTlsVerify: true } }); + + const nodes = await handlers[METHOD_LIST_NODES_FULL]({}, ctx); + assert.equal(nodes.http_status, 200); + assert.equal(nodes.nodes.length, 2); + assert.equal(nodes.nodes[0].node, 'pve-node-1'); + + const vms = await handlers[METHOD_LIST_QEMU_VMS_FULL]({ node: 'pve-node-1' }, ctx); + assert.equal(vms.vms.length, 2); + assert.equal(vms.vms[0].vmid, 100); + + const cfg = await handlers[METHOD_GET_QEMU_VM_CONFIG_FULL]({ node: 'pve-node-1', vmid: 100 }, ctx); + assert.equal(cfg.vmid, 100); + assert.equal(cfg.memory, 2048); + + const lxcs = await handlers[METHOD_LIST_LXCS_FULL]({ node: 'pve-node-1' }, ctx); + assert.equal(lxcs.containers.length, 1); + assert.equal(lxcs.containers[0].vmid, 200); + + const storages = await handlers[METHOD_LIST_STORAGE_FULL]({ node: 'pve-node-1' }, ctx); + assert.equal(storages.storages.length, 2); + assert.equal(storages.storages[0].storage, 'local'); + + const status = await handlers[METHOD_GET_NODE_STATUS_FULL]({ node: 'pve-node-1' }, ctx); + assert.equal(status.status, 'online'); + assert.equal(status.load_average_1m, 0.12); + assert.equal(status.kernel_version, 'Linux 6.8.4-2-pve'); + + for (const r of mock.requests) { + assert.match(r.path, /^\/api2\/json\//, `unexpected path: ${r.path}`); + assert.equal(r.headers.authorization, `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`); + } + } finally { + await mock.close(); + } +}); + +test('mock upstream rejects requests with bad token', async () => { + const mock = createMockServer({ expectedTokenId: 'someone@pam!other', expectedTokenSecret: 'deadbeef' }); + const { baseUrl } = await mock.start(); + try { + setFetch(originalFetch); + const ctx = buildCtx({ config: { baseUrl, allowInsecureHttp: true } }); + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, ctx), + 'PERMISSION_DENIED', + (err) => assert.match(err.message, /http 403/), + ); + } finally { + setFetch(originalFetch); + await mock.close(); + } +}); + +test('mock upstream rejects missing Authorization', async () => { + const mock = createMockServer(); + const { baseUrl } = await mock.start(); + try { + const res = await fetch(`${baseUrl}/api2/json/nodes`); + assert.equal(res.status, 401); + } finally { + await mock.close(); + } +}); + +test('mock upstream returns 404 for unknown paths', async () => { + const mock = createMockServer(); + const { baseUrl } = await mock.start(); + try { + const res = await fetch(`${baseUrl}/api2/json/unknown`, { + headers: { Authorization: mock.validAuthHeader }, + }); + assert.equal(res.status, 404); + } finally { + await mock.close(); + } +}); + +test('rpcdef merges context request with incoming request', async () => { + let url; + setFetch(async (u) => { + url = String(u); + return responseOf(200, { data: [] }); + }); + const defs = rpcdef(buildCtx({ req: { node: 'from-ctx' } })); + await defs[METHOD_LIST_QEMU_VMS_PATH]({ node: 'from-call' }); + assert.equal(url, 'https://pve.example.com:8006/api2/json/nodes/from-call/qemu'); +}); + +test('rpcdef falls back to context request when call argument is nullish', async () => { + let url; + setFetch(async (u) => { + url = String(u); + return responseOf(200, { data: [] }); + }); + const defs = rpcdef(buildCtx({ req: { node: 'ctx-only' } })); + await defs[METHOD_LIST_QEMU_VMS_PATH](null); + assert.equal(url, 'https://pve.example.com:8006/api2/json/nodes/ctx-only/qemu'); +}); + +test('helper functions cover normalization, mapping, and validation', () => { + assert.equal(_test.grpcCodeFor('NOPE'), grpcStatus.UNKNOWN); + assert.equal(_test.engineError('FAILED_PRECONDITION', 'x').code, grpcStatus.FAILED_PRECONDITION); + assert.equal(_test.hasOwn(null, 'x'), false); + assert.equal(_test.unwrapScalar({ value: 'a' }), 'a'); + assert.equal(_test.unwrapScalar(undefined), undefined); + assert.equal(_test.pickString(null), ''); + assert.equal(_test.pickString(12), '12'); + assert.equal(_test.pickFirstString([undefined, ' a ']), 'a'); + assert.equal(_test.pickFirstString([' ', undefined]), ''); + assert.equal(_test.pickInt('42'), 42); + assert.equal(_test.pickInt(null), 0); + assert.equal(_test.pickLong('999999999'), 999999999); + assert.equal(_test.pickDouble('1.5'), 1.5); + assert.equal(_test.pickBoolean('yes'), true); + assert.equal(_test.pickBoolean('off'), false); + assert.equal(_test.pickBoolean('maybe'), undefined); + assert.equal(_test.pickFirstBoolean(['bad', 'true']), true); + assert.equal(_test.normalizeBaseUrl('https://pve.example.com:8006'), 'https://pve.example.com:8006'); + assert.equal(_test.normalizeBaseUrl('https://pve.example.com:8006///'), 'https://pve.example.com:8006'); + assert.equal(_test.normalizeBaseUrl('ftp://x'), ''); + assert.equal(_test.normalizeBaseUrl(''), ''); + assert.equal(_test.isValidNodeName('pve-node-1'), true); + assert.equal(_test.isValidNodeName('pve_node.2'), true); + assert.equal(_test.isValidNodeName('has space'), false); + assert.equal(_test.isValidVmid(100), true); + assert.equal(_test.isValidVmid(0), false); + assert.equal(_test.isValidVmid('200'), true); + assert.equal(_test.isValidVmid('abc'), false); + assert.equal(_test.requireVmid({ vmid: '1' }, 'X'), 1); + assert.throws(() => _test.requireVmid({ vmid: '' }, 'X'), /INVALID_ARGUMENT/); + assert.throws(() => _test.requireVmid({ vmid: 0 }, 'X'), /INVALID_ARGUMENT/); + assert.throws(() => _test.requireVmid({ vmid: 'x' }, 'X'), /INVALID_ARGUMENT/); + assert.throws(() => _test.requireNodeName({}, { defaultNode: '' }, 'X'), /INVALID_ARGUMENT/); + assert.throws(() => _test.requireNodeName({ node: 'bad name' }, { defaultNode: '' }, 'X'), /INVALID_ARGUMENT/); + assert.equal(_test.requireNodeName({ node: 'a' }, {}, 'X'), 'a'); + assert.equal(_test.requireNodeName({}, { defaultNode: 'b' }, 'X'), 'b'); + assert.equal(_test.resolveToken({ tokenId: 'a@b!c', tokenSecret: 's' }).tokenId, 'a@b!c'); + assert.throws(() => _test.resolveToken({ tokenId: 'a', tokenSecret: 's' }), /USER@REALM/); + assert.throws(() => _test.resolveToken({ tokenId: '', tokenSecret: 's' }), /tokenId/); + assert.throws(() => _test.resolveToken({ tokenId: 'a@b!c', tokenSecret: '' }), /tokenSecret/); + assert.throws(() => _test.buildAuthHeader({ tokenId: '', tokenSecret: 's' }), /INVALID_ARGUMENT/); + assert.equal(_test.buildAuthHeader({ tokenId: 'a@b!c', tokenSecret: 's' }), 'PVEAPIToken=a@b!c=s'); + assert.throws(() => _test.resolveBaseUrl({ baseUrl: '' }), /baseUrl/); + assert.throws(() => _test.resolveBaseUrl({ baseUrl: 'http://insecure.local' }), /https/); + assert.equal(_test.resolveBaseUrl({ baseUrl: 'http://insecure.local', allowInsecureHttp: true }), 'http://insecure.local'); + assert.throws(() => _test.resolveBaseUrl({ baseUrl: 'ftp://x' }), /baseUrl/); + assert.equal(_test.resolveTimeoutMs(), 5000); + assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 10 } }), 10); + assert.equal(_test.resolveTimeoutMs({ bindings: { timeout_ms: 20 } }), 20); + assert.equal(_test.resolveTimeoutMs({ bindings: { timeout: 30 } }), 30); + assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 'bad' } }), 5000); + assert.deepEqual(_test.buildTlsOptions({}), {}); + assert.deepEqual(_test.buildTlsOptions({ skipTlsVerify: true }), { + skipTlsVerify: true, + tlsInsecureSkipVerify: true, + insecureSkipVerify: true, + }); + assert.equal(_test.shouldSkipTls({ tlsInsecureSkipVerify: 'on' }), true); + assert.equal(_test.shouldSkipTls({ tls_skip_verify: 'yes' }), true); + assert.equal(_test.shouldSkipTls({}), false); + assert.deepEqual(_test.sanitizeHeaders({ a: 1, b: { value: false } }), { a: '1', b: 'false' }); + assert.deepEqual(_test.sanitizeHeaders(null), {}); + assert.deepEqual(_test.sanitizeHeaders(['skip']), {}); + assert.equal(_test.buildHeaders({ headers: { Extra: '1' } }, 'AUTH').Extra, '1'); + assert.equal(_test.buildHeaders({}, 'AUTH').Authorization, 'AUTH'); + assert.equal(_test.mapHttpStatus(401), 'PERMISSION_DENIED'); + assert.equal(_test.mapHttpStatus(403), 'PERMISSION_DENIED'); + assert.equal(_test.mapHttpStatus(400), 'FAILED_PRECONDITION'); + assert.equal(_test.mapHttpStatus(404), 'FAILED_PRECONDITION'); + assert.equal(_test.mapHttpStatus(500), 'UNAVAILABLE'); + assert.equal(_test.mapHttpStatus(502), 'UNAVAILABLE'); + assert.throws(() => _test.parseJsonBody('not json'), /INVALID_ARGUMENT|UNKNOWN/); + assert.throws(() => _test.parseJsonBody(''), /UNKNOWN/); + assert.deepEqual(_test.parseJsonBody('{"a":1}'), { a: 1 }); + assert.equal(_test.buildUrl('https://x.com/', ['nodes', 'pve-1', 'qemu'], { full: 1 }), 'https://x.com/api2/json/nodes/pve-1/qemu?full=1'); + assert.equal(_test.buildUrl('https://x.com', ['nodes'], {}), 'https://x.com/api2/json/nodes'); + assert.equal(_test.buildUrl('https://x.com', []), 'https://x.com/api2/json'); + assert.equal(_test.wrapRawBody('hello'), 'hello'); + assert.equal(_test.wrapRawBody(null), ''); + assert.equal(_test.asJsonValue(null), null); + assert.equal(_test.asJsonValue(undefined), null); + assert.deepEqual(_test.asJsonValue({ a: 1 }), { a: 1 }); + assert.deepEqual(_test.arrayOrEmpty([1, 2]), [1, 2]); + assert.deepEqual(_test.arrayOrEmpty(null), []); + assert.deepEqual(_test.arrayOrEmpty('x'), []); + assert.deepEqual(_test.extractData({ data: [1] }), [1]); + assert.deepEqual(_test.extractData([1, 2]), [1, 2]); + assert.equal(_test.extractData(null), null); + assert.deepEqual(_test.buildNodeInfo({ node: 'n', status: 'online', cpu: 0.5, ssl_fingerprint: 'AB:CD' }), { + node: 'n', + status: 'online', + cpu_usage: 0.5, + cpu_count: 0, + max_cpu: 0, + mem_total: 0, + mem_used: 0, + disk_total: 0, + disk_used: 0, + uptime: 0, + level: '', + ip: '', + maxmem: 0, + maxdisk: 0, + raw: { node: 'n', status: 'online', cpu: 0.5, ssl_fingerprint: 'AB:CD' }, + ssl_fingerprint: 'AB:CD', + }); + assert.equal(_test.buildNodeInfo({ node: 'n' }).ssl_fingerprint, ''); + + // QemuVMInfo: extended fields + assert.equal(_test.buildQemuVMInfo({ vmid: 100, name: 'a', cpu: 0.05, diskread: 100, netin: 200, pid: 999, tags: 'prod' }).disk_read, 100); + assert.equal(_test.buildQemuVMInfo({ vmid: 100 }).net_in, 0); + assert.equal(_test.buildQemuVMInfo({ vmid: 100, 'running-machine': 'pc-q35-9.0' }).running_machine, 'pc-q35-9.0'); + assert.equal(_test.buildQemuVMInfo({ vmid: 100, pressurecpufull: 0.5 }).pressure_cpu_full, 0.5); + assert.equal(_test.buildQemuVMInfo({ vmid: 100 }).pressure_cpu_full, 0); + + // LXCInfo: extended fields + assert.equal(_test.buildLXCInfo({ vmid: 200, name: 'lxc', maxswap: 4096, tags: 'web' }).max_swap, 4096); + assert.equal(_test.buildLXCInfo({ vmid: 200 }).max_swap, 0); + assert.equal(_test.buildLXCInfo({ vmid: 200, pressureiosome: 0.3 }).pressure_io_some, 0.3); + + // StorageInfo: formats_json and select_existing + const si = _test.buildStorageInfo({ storage: 's1', type: 'dir', total: 100, used: 25, shared: 1, formats: { supported: ['qcow2', 'raw'], default: 'qcow2' }, select_existing: 1 }); + assert.equal(si.used_fraction, 0.25); + assert.match(si.formats_json, /qcow2/); + assert.equal(si.select_existing, true); + assert.equal(_test.buildStorageInfo({}).select_existing, false); + + // NodeStatus: extended fields + const ns = _test.buildNodeStatus({ + node: 'n', status: 'online', uptime: 100, loadavg: [1, 2, 3], + cpu_count: 4, cpu_usage: 0.5, memory: { total: 100, used: 50, free: 50, available: 80 }, + 'boot-info': { mode: 'efi', secureboot: true }, + 'current-kernel': { sysname: 'Linux', release: '6.8', version: '#1', machine: 'x86_64' }, + rootfs: { total: 200, used: 50, free: 150, avail: 100 }, + idle: 12345, ksm: { shared: 5 }, wait: 0.05, + }, 'n'); + assert.equal(ns.boot_info_mode, 'efi'); + assert.equal(ns.boot_info_secureboot, true); + assert.equal(ns.current_kernel_sysname, 'Linux'); + assert.equal(ns.current_kernel_release, '6.8'); + assert.equal(ns.current_kernel_machine, 'x86_64'); + assert.equal(ns.memory_available, 80); + assert.equal(ns.rootfs_total, 200); + assert.equal(ns.rootfs_used, 50); + assert.equal(ns.rootfs_free, 150); + assert.equal(ns.rootfs_available, 100); + assert.equal(ns.idle, 12345); + assert.equal(ns.ksm_shared, 5); + assert.equal(ns.wait, 0.05); + assert.equal(_test.buildNodeStatus({}, 'fb').node, 'fb'); + + // QemuVMConfig: extended fields + const qc = _test.buildQemuVMConfig({ vmid: 7, name: 'vm', memory: 1024, description: 'test', tags: 'web', template: 1, onboot: 'yes', autostart: 1, cpu: 'host', cpulimit: '2.0', cpuunits: 1024, bios: 'ovmf', machine: 'pc-q35-9.0', arch: 'x86_64', agent: '1', hugepages: '1024', keephugepages: 1, vmgenid: 'g1', protection: 0, lock: 'backup', balloon: 2048, digest: 'sha256=x', hotplug: 'network,disk', keyboard: 'en-us', kvm: 1 }, 'pve-1', 7); + assert.equal(qc.description, 'test'); + assert.equal(qc.tags, 'web'); + assert.equal(qc.template, true); + assert.equal(qc.onboot, true); + assert.equal(qc.autostart, true); + assert.equal(qc.cpu, 'host'); + assert.equal(qc.cpulimit, 2.0); + assert.equal(qc.cpuunits, 1024); + assert.equal(qc.bios, 'ovmf'); + assert.equal(qc.machine, 'pc-q35-9.0'); + assert.equal(qc.arch, 'x86_64'); + assert.equal(qc.agent, true); + assert.equal(qc.hugepages, '1024'); + assert.equal(qc.keephugepages, true); + assert.equal(qc.vmgenid, 'g1'); + assert.equal(qc.protection, false); + assert.equal(qc.lock_status, 'backup'); + assert.equal(qc.balloon, 2048); + assert.equal(qc.digest, 'sha256=x'); + assert.equal(qc.hotplug, 'network,disk'); + assert.equal(qc.keyboard, 'en-us'); + assert.equal(qc.kvm, true); + assert.equal(_test.buildQemuVMInfo({ vmid: 100, name: 'a' }).vmid, 100); + assert.equal(_test.buildQemuVMInfo({ vmid: 'x' }).vmid, 0); + assert.equal(_test.buildLXCInfo({ vmid: 200, name: 'lxc' }).vmid, 200); + assert.equal(_test.buildStorageInfo({ storage: 's1', type: 'dir', total: 100, used: 25, shared: 1 }).used_fraction, 0.25); + assert.equal(_test.buildStorageInfo({ storage: 's2', total: 100, used: 25, shared: 0 }).shared, false); + assert.equal(_test.buildNodeStatus({ node: 'n', status: 'online', loadavg: [1, 2, 3], cpu_count: 4, cpu_usage: 0.5, memory: { total: 10, used: 5, free: 5 }, swap: { total: 1, used: 0, free: 1 } }, 'n').load_average_5m, 2); + assert.equal(_test.buildNodeStatus({}, 'fallback').node, 'fallback'); + assert.equal(_test.buildQemuVMConfig({ vmid: 7, name: 'vm', memory: 1024 }, 'pve-1', 7).vmid, 7); + assert.equal(_test.buildQemuVMConfig({ vmid: '7' }, 'pve-1', 999).vmid, 7); + assert.equal(_test.valueOrZeroLong('123'), 123); + assert.equal(_test.valueOrZeroLong(null), 0); + assert.equal(_test.valueOrZeroLong('bad'), 0); + assert.equal(_test.valueOrZeroDouble('1.25'), 1.25); + assert.equal(_test.valueOrZeroDouble(null), 0); + assert.equal(_test.resolveVmidString('1'), 1); + assert.equal(_test.resolveVmidString('x'), 0); + assert.equal(_test.resolveVmidString(null), 0); + assert.deepEqual(_test.resolveCallContext(), { bindings: {}, limits: {}, meta: {}, req: {} }); + assert.deepEqual(_test.resolveCallContext({ request: { node: 'a' } }).req, { node: 'a' }); + assert.deepEqual(_test.resolveCallContext({ config: { a: 1 }, secret: { b: 2 }, bindings: { c: 3 } }).bindings, { a: 1, b: 2, c: 3 }); + + const logs = []; + console.log = (...args) => logs.push(args); + _test.logFlow({ meta: { instance_id: 'i', request_id: 'r' } }, 'phase', { ok: true }); + assert.match(logs[0][0], /\[Proxmox_VE_8_3_5\]\[phase\]\[inst=i req=r\]/); + const circular = {}; + circular.self = circular; + _test.logFlow({}, 'fallback', circular); + assert.equal(logs[1][0], '[Proxmox_VE_8_3_5][fallback]'); +}); + +test('network failure maps to UNAVAILABLE', async () => { + setFetch(async () => { throw Object.assign(new Error('connect refused'), { cause: new Error('ECONNREFUSED') }); }); + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), + 'UNAVAILABLE', + (err) => assert.match(err.message, /ECONNREFUSED/), + ); +}); + +test('http 200 with empty body maps to UNKNOWN', async () => { + setFetch(async () => responseOf(200, '')); + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), + 'UNKNOWN', + ); +}); \ No newline at end of file From a3d89e25ea49ed6c876ddecf612b68253b17811a Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 18:44:53 +0800 Subject: [PATCH 02/19] add integration evidence --- services/proxmox__ve_8-3-5/integration-evidence.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 services/proxmox__ve_8-3-5/integration-evidence.md diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md new file mode 100644 index 00000000..e082cafd --- /dev/null +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -0,0 +1,12 @@ +## 联调证据 + +> 本地真实联调:本地启动 mock upstream → gRPC handler 直接调用 → 日志打印完整 HTTP request/response。 + +### gRPC Request + + +### Upstream HTTP Response + + +### gRPC Response + From 8ebd8312791773328682dbdf8fce8b5bc1add538 Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 18:46:38 +0800 Subject: [PATCH 03/19] add integration evidence --- services/proxmox__ve_8-3-5/integration-evidence.md | 1 - 1 file changed, 1 deletion(-) diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md index e082cafd..3de45bc5 100644 --- a/services/proxmox__ve_8-3-5/integration-evidence.md +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -9,4 +9,3 @@ ### gRPC Response - From 21a79c87559ef147ab5d05435a2106ee4256e264 Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 23:22:28 +0800 Subject: [PATCH 04/19] add real integration evidence --- .../proxmox__ve_8-3-5/integration-evidence.md | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md index 3de45bc5..3de998a7 100644 --- a/services/proxmox__ve_8-3-5/integration-evidence.md +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -1,11 +1,32 @@ -## 联调证据 +## 联调证据:Proxmox VE 8.3.5 跑通 (issue #103) -> 本地真实联调:本地启动 mock upstream → gRPC handler 直接调用 → 日志打印完整 HTTP request/response。 +> 本地真实联调:本地启动 mock upstream 模拟目标服务 → 通过 gRPC handler 直接调用 → 服务日志输出完整 HTTP request/response。 -### gRPC Request +### 1. Mock upstream 启动 +``` +$ node test/mock_upstream.js # 启动 mockup at http://127.0.0.1:55287 +Listening on 127.0.0.1:... +``` +### 2. 实际执行 (Node.js script) +```javascript +import { service } from './src/service.js'; +const result = await service.handlers['Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes']( + {}, // empty request + { config: {"baseUrl":"http://127.0.0.1:55287","allowInsecureHttp":true}, secret: {"token_id":"root@pam!octobus","token_secret":"REDACTED"} } +); +``` -### Upstream HTTP Response +### 3. 服务实际发出的 Upstream HTTP Request (handler 日志) +``` +(no log line for this method) +``` +### 4. Upstream HTTP Response (handler 日志捕获的状态/长度) +``` +``` -### gRPC Response +### 6. gRPC Response (handler 返回值) +```json +(handler 调用失败) +``` From e165d73017e925486f5cfdc81913d0e913334977 Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 23:25:15 +0800 Subject: [PATCH 05/19] add real integration evidence --- .../proxmox__ve_8-3-5/integration-evidence.md | 138 ++++++++++++++++-- 1 file changed, 126 insertions(+), 12 deletions(-) diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md index 3de998a7..40c862f2 100644 --- a/services/proxmox__ve_8-3-5/integration-evidence.md +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -4,8 +4,8 @@ ### 1. Mock upstream 启动 ``` -$ node test/mock_upstream.js # 启动 mockup at http://127.0.0.1:55287 -Listening on 127.0.0.1:... +$ cd proxmox__ve_8-3-5/test && node mock_upstream.js & +# Mock started at: http://127.0.0.1:55335 ``` ### 2. 实际执行 (Node.js script) @@ -13,20 +13,134 @@ Listening on 127.0.0.1:... import { service } from './src/service.js'; const result = await service.handlers['Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes']( {}, // empty request - { config: {"baseUrl":"http://127.0.0.1:55287","allowInsecureHttp":true}, secret: {"token_id":"root@pam!octobus","token_secret":"REDACTED"} } + { config: {"baseUrl":"http://127.0.0.1:55335","allowInsecureHttp":true}, secret: {"token_id":"root@pam!automation","token_secret":"11111111-2222-3333-4444-555555555555"} } ); ``` -### 3. 服务实际发出的 Upstream HTTP Request (handler 日志) -``` -(no log line for this method) -``` - -### 4. Upstream HTTP Response (handler 日志捕获的状态/长度) -``` +### 3. 上游 API 实际响应体 (handler.raw_body 解析) +```json +{ + "data": [ + { + "node": "pve-node-1", + "status": "online", + "level": "c", + "ip": "10.0.0.11", + "cpu": 0.12, + "cpu_count": 16, + "maxcpu": 16, + "mem": 8589934592, + "maxmem": 34359738368, + "disk": 107374182400, + "maxdisk": 536870912000, + "uptime": 9000 + }, + { + "node": "pve-node-2", + "status": "offline", + "level": "", + "ip": "10.0.0.12", + "cpu": 0, + "cpu_count": 8, + "maxcpu": 8, + "mem": 0, + "maxmem": 16777216000, + "disk": 0, + "maxdisk": 268435456000, + "uptime": 0 + } + ] +} ``` -### 6. gRPC Response (handler 返回值) +### 4. gRPC Response (handler 返回值) ```json -(handler 调用失败) +{ + "http_status": 200, + "raw_json": { + "data": [ + { + "node": "pve-node-1", + "status": "online", + "level": "c", + "ip": "10.0.0.11", + "cpu": 0.12, + "cpu_count": 16, + "maxcpu": 16, + "mem": 8589934592, + "maxmem": 34359738368, + "disk": 107374182400, + "maxdisk": 536870912000, + "uptime": 9000 + }, + { + "node": "pve-node-2", + "status": "offline", + "level": "", + "ip": "10.0.0.12", + "cpu": 0, + "cpu_count": 8, + "maxcpu": 8, + "mem": 0, + "maxmem": 16777216000, + "disk": 0, + "maxdisk": 268435456000, + "uptime": 0 + } + ] + }, + "nodes": [ + { + "node": "pve-node-1", + "status": "online", + "cpu_usage": 0.12, + "cpu_count": 16, + "max_cpu": 16, + "mem_total": 34359738368, + "mem_used": 8589934592, + "disk_total": 536870912000, + "disk_used": 107374182400, + "uptime": 9000, + "level": "c", + "ip": "10.0.0.11", + "maxmem": 34359738368, + "maxdisk": 536870912000, + "raw": { + "node": "pve-node-1", + "status": "online", + "level": "c", + "ip": "10.0.0.11", + "cpu": 0.12, + "cpu_count": 16, + "maxcpu": 16, + "mem": 8589934592, + "maxmem": 34359738368, + "disk": 107374182400, + "maxdisk": 536870912000, + "uptime": 9000 + }, + "ssl_fingerprint": "" + }, + { + "node": "pve-node-2", + "status": "offline", + "cpu_usage": 0, + "cpu_count": 8, + "max_cpu": 8, + "mem_total": 16777216000, + "mem_used": 0, + "disk_total": 268435456000, + "disk_used": 0, + "uptime": 0, + "level": "", + "ip": "10.0.0.12", + "maxmem": 16777216000, + "maxdisk": 268435456000, + "raw": { + "node": "pve-node-2", + "status": "offline", + "level": "", + "ip": "10.0.0.12", + "cpu": 0, + "cpu_count" ``` From e237428a106cf1ad8e4551b2a51a16e48dfc1ea5 Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 23:28:11 +0800 Subject: [PATCH 06/19] add sdk bundle and offline-test --- .../proxmox__ve_8-3-5/offline-test/README.md | 14 ++++++++++++++ .../offline-test/config.example.json | 10 ++++++++++ .../sdk/chaitin-ai-octobus-sdk-0.5.0.tgz | Bin 0 -> 37527 bytes 3 files changed, 24 insertions(+) create mode 100644 services/proxmox__ve_8-3-5/offline-test/README.md create mode 100644 services/proxmox__ve_8-3-5/offline-test/config.example.json create mode 100644 services/proxmox__ve_8-3-5/sdk/chaitin-ai-octobus-sdk-0.5.0.tgz diff --git a/services/proxmox__ve_8-3-5/offline-test/README.md b/services/proxmox__ve_8-3-5/offline-test/README.md new file mode 100644 index 00000000..98285db9 --- /dev/null +++ b/services/proxmox__ve_8-3-5/offline-test/README.md @@ -0,0 +1,14 @@ +# Offline Test + +Use this directory for customer-site read-only checks against a real +Proxmox VE 8.3.5 deployment. Start with status or list methods before running any +write operation. + +Recommended first checks: +- `ListNodes` +- `ListQemuVMs` + +Replace placeholder values in `config.example.json` with the +customer's actual baseUrl, username/password (or bearer token) before +running the service entry. The bundled SDK is the same version used to +build the service package and ships in `sdk/chaitin-ai-octobus-sdk-0.5.0.tgz`. diff --git a/services/proxmox__ve_8-3-5/offline-test/config.example.json b/services/proxmox__ve_8-3-5/offline-test/config.example.json new file mode 100644 index 00000000..020db15c --- /dev/null +++ b/services/proxmox__ve_8-3-5/offline-test/config.example.json @@ -0,0 +1,10 @@ +{ + "config": { + "baseUrl": "https://PVE_HOST:8006", + "allowInsecureHttp": false + }, + "secret": { + "token_id": "root@pam!TOKENID", + "token_secret": "PROXMOX_TOKEN" + } +} diff --git a/services/proxmox__ve_8-3-5/sdk/chaitin-ai-octobus-sdk-0.5.0.tgz b/services/proxmox__ve_8-3-5/sdk/chaitin-ai-octobus-sdk-0.5.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..171c4d838042e4373ab31b3b9b20324e3dab7fb9 GIT binary patch literal 37527 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%0DElO)iA-Oy3Nn(+~l+rlHHC`O3!Qp&Pm# z%n?kMGaiaQt3KHnv>eI!w~-f3==yh0_s$u=SAB^qlh)tmBT?7QhJ=fF2$v>x8FH%# z3F$+AAud_)%Z%QrFJEZw6p3O%F(TsZ ztX87N>@n{CfDit9J!DmxpIPsoBq)-D#kxjP1crZ>G@kP3sisvT7f{gN($QSK^<@@t z0z#T)gre+uF&Jnzs#;o6si zn5soD6dNZvm>;cEt(hRLFcrWmeHN)AV`GP~ z&7xgT=72ZBvKNFtYmV8=)N>A_8oia5+C$wLg>&YLa?W%>*$Zc_38l@5XJEAtvgQ9- zUH;F*PWxbU`TrhvR^9{X`)kYhAGA7=`UmfRE7QYosMfNqQH@`Y&+`c(;M;bFFI6{S zRNB%fmNn{hx{WEmo&K&HHUG}Q>v#VCFW=E_x{qf2uLlpm3*P_N9zR~Yd;h~5)Tog%eXmR>6thbszPFK}eaXZ8kbky3X5C6Ztr)_KFNb)}G zSIjEUbAjwgyyL8F$jX3iE*LnFv%AmUu4j-2&{{}3jpUaD{`*x`SHDenj|9O=vXMU| zXr`ySySlpGx~e)l-~W+h?+E~UImx5rK?li8iEO60yeHDQBZ>drp zTy}?e46vY8b=G zMX#G*?~&tKH%{AhXMKYksL9C76-^wfdm#J6n}6k0vqQrdL~aa&3Y*~P#0M1Y%YsN@^d!r&gQwwfgr8n+Snch~90( zcoYEfz41-u-=}!f6Uu)KrG0x)zv+C1K1${P`sOOWv*rKl*5)GqZ!Z77i2_2uQhAIl zuT;MICi?G5xBUSugmg5nRQ4#tE=nUXFS}sZ6~DpS3YV9IUZ+v1BuR3S=GT?}@N;Nb z(FN889goss^gpnZYc)D@{&U*C$yT!V^`Iufi?@TxII3GOu#-yVmA(NqkH**OIO=D7 zxz_}*D8PddIoYiLu{#>{(NzmGyy@ zp<9wiFsGUGi;D@Gh&32lE^u)&;xtHDB)KTS-Y0aM9ZuLjI!NNkDU%P+3^!U#&Y~1i zNw=_yOOI)i_(A1XNK~qTHT|wqrJc6ZUVG9@$LIj8NR33x5m7J})ChjTH;QWYLOfs5 zVCvjZ$tbHRYeW9#y3AU#SFPHp4f)$@2NH&mYt7lG#ail;F~tVGGDmCbOab8@(BDz` z37wsV9}VqYP6s*J4M^5#l=flSagVd60pwZS-Q*X*yYB{ zK;0bvCw4;jqLyfdv_!FA(PiYU^s|_JdWU^FooJ4(Cb$L3ArT{O zsWRSOqexu<1J5g!V#KeV>4q3I(%Fj30mN9}Q@{A5m1YN3z@_&1vvrrO=;=1Rp~}FeGHCKH~s9&D$uI^{7&GX zI3{b*Xrn)h5t!GI(|8X%4I2JcHh#;&AWPNHHGW&e(%4k$Q@NOB#A7T-7Ttgqd(Bh~ zP+ZdR*;ooN62|Ch9nACa{*MM z9&Ccv$EbTTF)!H=HBMcoSC3GPV14!8xLYgime}T+6br+%5=Rp>AOeiA_}HCJqv&={ zlt~f*N!25~A}`v&?n4DkSh#paY8g;@v{>2* z7|Rf5KT8`J0h$;yHWeJ4>oCsvjo{||%`DxCD=cgIcya;F0f7LDQ>^+3a##LU6D@%t z9t96M001Mq>_Lzuc?bo-9iXF6XvW7KSxSZTR&3z2iO_E(sfb-BoFP3y)oDZ*Jw{h& zr+bmOiPNM_kOh$=^q^b0Z^4FyUo`Zs)zz&vy}-05zrDTxRt)77w*T1BFli;loxW

U1aO?Fz%Q7Gm<8VFJQ=4T-5xTDAaTzAp+%uum=_1m!`wSM ztVe&r`Sk~Q>odbkfJXpp{fvAN{>lLlz_S88I*IUX2ZRg-zyRoBbs*UygZ@HPDR>Ub&ggpTs zfNt0z;7NU(iz+G@{xnM4p_!p$U2=J!W!sT}k8>@8<_+KOOEJyx%)HZXNu!m6!^q z=o9gPNQo|c>6J+_0T4St0Bv>x0x6^yS+Clt(y*)dF)Ql$DjjxPfJD%}(KA&W)8F?* zDy7Rhjnu@F!vc`aoc3~YT{J9saMH9@5==Boq>0#`j7DT`4n`jU1r_B{2DPUTyp#wO zRRz*v($4cp#C$LmdG8x8II2+HKI#@(hbW^WjtzAvmr?Ubn0q3s3KlvMs7wR_3gjiC z)-6yORW!>hilM?ldX}+_F1rzuf*6+2!?G@oG#^Yxcy7;4aL;8GQjBoP6S-NxOPJC` z=kSw=Z^$+4W ztv^FQa(%7wr`1*APHv~eJR{o!Hz_@mT#gJ*+0H0L52l}BtUMy=T*&`E&A6-n zqYmaB4HdU}BU;1mX`?ryb;^}h`!w4}7xNr2$$-N2$m2j_32PoPf^ISk9(+J*I< zQAMAoYSwC(DKQF(cr~p(LsM&6UMM+23`)#1ZAgLQp3?6L22ueBb8ti$s@LnF9(=mS zbk9!KBf}H6u%cEeJ^B2Qn82G>>y+shtdzt@Bq)1Y12s&*DabL9UGOJ5-PUqR8HHPLa%jGW{Z7Nl4llBhWC(F3s)c22&eeu*5On z+ui|nBM6d@;8}IdQmIcyJs9K|wOvWi4jnTqC?RK{y_a8PWjy(TTw4%HD1$$Z@MOtS zx|Nt_EU}O7i%OuWN2sSKmJ>FSUgreqn)n;A4j_P=?s$h3D+G3vjy^t&q>Ll9 zPRz2BJ$N=?_0aJ`>rs>RH<=XeG!n#I=2VEdD@V4WmM9Mea9?8xqIRqI#aiKKv^Gi% z+U)WaWJ@>CfV+JGUJ_!QPs1?DmJaVD3{B(-?ncHYEZE);;HEN+QL-O#XBu)!g)a~v zu$^RpX`gbt(wsx>!y?)@XkCLLIQX!z_5dU~2pTX5)f@!xY_giCUJ%v4J~3WlpzYp3 z1nQ|}Wy}cuS?gF}#*8^#RU{0P_aKodNVM;de&DXu6HZ7iXDbO*@}?>(s^PID%O}HO z4`tba%>pqTbkYNbA>AmVu(0FjY%uRzg#?8H;iA1&ePb;*+q2uic!~e5t*viVfv%vr ziJ>sz^KMpzKZ_ax)?*Lwn~c&=iQqC~MnZpe8B1bCDkRF;R4=Gyh8Ce^O6>wT334ao zj*np~Az4qnrA*Kp8!;s9jV1=N5hJchmp3ds=pZMMA%@8Q0oY>R)fnc3A1Fr~ z{LcEhsJzSHsx4)I1xD|PjvHt?J{!KF7_d=_sTX$^B(iF2vOz1b~?E4ekKCSkgCNi($uszU4|cYmTF z!eGde^ad6Z?Zh~w7fCSo4icR914`$j*pGA|nIp5p6Uzj5D?K7F4thjUY@t}kTHXVp z^T-=ZhFQCN*=_UL09B%laI<<8A7pUHgYhf8`kBvz@zH5E0*QlLWFObse05In;z*5X z7FUKoceOfOs^YOBxv!`@aoMJB>>b8Zm0XN4Tj7e+pI~O18rG5?Y~85q7f02}P^=%W z>c_O(!()f4l*A{aPL~#&K=x%0B(B^IGJ1+^Nn{a0W}b(_)G$S!wm(8qK`MIYsyDb` z8kpoo(P`K*SLL8uhWUgnUj-k)fGH4@O-tMiD)m1x0q+qI?m)u61cRT?NKeJ*l?w7- zun{vd{7Bz&0bhI}sIR-@H((qz+ae7WdD3MM)`fJsRK7kFz^pL}RxjU-(<|Xro$ygi z0+XzLe8GN?-Gs9|nb{f7jYDDl#)`$SbF&0FFR3=rp$uOJjU1z@@XVHW69JO;T4v6U zJ@Co#>D%|MlOGR`6CurpidM<|5HqvzYc5$*w-;Hubifd(<6#^hD4~i3s?VTy)g9LW zEj4aef#2jtFHX{YPy^obfu2%x!C z14K?86y=ObH2{H#sSlFf{Hm2+u_oLZ7zvLK(ZOJy1_27Y1hxWZ2h30H7tGaMgv$P2 z>VK*L5OVg-tN!!$*0yv1>+0sB{_{NRKVxKK^inQATV-$YAQV#$qBnUbD)opmDK9S^ZK(o-y5t)L7x+;tN4_-#;|GLlW(T&vQQ;=Fp5U+A;3> z)Z;0RgX&VsL5#2M-uVg~`{9iki?|!f+s9wf7aW=}8E&9$&xRP#=)nN82LK$oote|b zhaguD`XAvb`d$C)ynZSQR>HH{hj=EOYb8Bn%uK_JLz#`u&S&2ufVJI2pHKdWCmfsY?C;RhCtoI8%2rmGy8D|%$8F@~v*Xt% z!gg@q>GKJFu4i5+PC#GliVogS-SX1*_x!Dh)2G{j^|u`9;rmRSc$ zt-{t4z@DmwCreq-?}o^m%&LNQUJvqus6ilF(y&CrT6&I{f`@qLRpEuO^Slp(tzUHt zkJ&BfG$w2oo=S(F@%SFJgyO7)UyG+L+)ut4%{&=r%=-AkGm|Hs2+OftTZ=9ET18_SJIn=zenyEu_|xduqphB) zfJa*m!#LL;%wbR;pNg2sLK>K;s?q_S3f`Y~+aF$VBhn?fQc6@My`;LU+GnHDgW7ld zy8E*XcEClC8_V&HM_*7mGSOd#1q}NvDy|_MWuLn^yCw9}N}8lZm_Ak|@^*x9Y^~GJ z896qb#7~_*Vro}s(BVzt>Gn|U+Cazlo|fgxvF85=ysS0=5gA$#AalC_1^|;l!GVwU zP7r*ueC6H%g$DqI_kjyWY%n}o3y(_1QB>tM0xTID0f|}Ck+W7!`X=yeh6T-CwU=G+nPzh0|>G*m{#2N{dsDLBIh}3pr zhI#j^+Y7P+|2d-&wYudT4zN71n z7euhCS#Cc4&_(x*uG!A#7ZpV~`Y|po1s~JG3LnFJ6@E+)1*<7M#87GD(eoJvxTcAa z_;#qq5OWXh;}wvO*bBNhfoO5xWy8Bu9z_lhpoSSHWa$Rjaj9Zpjcf+!?E>+s;`wDR zq^Z3YNxDqS(j(x$nwa>B*P%#7kKrCyOorxlGX$D*#jE|GHcMb7OOTQU7zE_P-%StSo$6$f}wRmLaPW zKmAVBLhFag=_eP+m)W?D&-h{m`YF%ID3J@^y%B#M0-pk@w6sKLn)XBK7B!ykF8hVD z!lyVLvN7uIl-<*qn%9Fj%?p5dPQQvLR2uj@eGd&t-z83KGac>P&l?yLO;b*cKihp? znL;79k^$`Xj4m)!Y_;&yJL274Qz3C-_&<__hErIwV@l%aQ3z;wrf*D+vdIk2JL4!F zKLq?Y&yD6HAxV01#4mX(N7|v3E^)+e#mUN7B2~(c2~jUQ**NX?a^oyp6{r*`SW6~k z<6b2#6;0@|1gP=4T;^!;R)0}ilb6*j7oRU25%aVEMH=l>$AFg0|J7$3PW<9zBJ&39XS@@`@M=v?HZsYknt;YqeFAw4&r zBj0J}U;6Ds=pWA+RfoI9FhDsKJG_v`Vn(+$adiAEudS#%t4)S=d7?B~TqW<$QH zlL2*4sPl=;ady6<$hpb`_dj2D`P3ocQ}+M%mUI8l`o_jW|DUh_&&`aki+wL{{xWyJ z>naJArY@)x%MIO4IW#-r`q5zgso0MO2YTHEOG}OEs?8^WCKI`5fHV*JZ>;xI@_;G( ze`9UU&Hr2Ef6UeXw@nkx3uZ7%3bE(~!$R5(McOVso>kQl%3;i7fBdfWf7<^~efl?T z|8H)tI_H1u&(=2=`+tt|e_F1W>2%_=9@0O_9kwIFN0sq#SM-c1MONyj@aN0P_4f1| zZYrLiC`XmqnxEQE*Pk2GYDC{U%c@+To;T7@R(!R){L8y}@BPc=bHA2MU*fsvEQfwn zbjKnLwnwFb3M6P7tjgfMi=1ZxLX(L-96KdtasVz1>GYEvFHyLi9Au8dXtX~W_Oj1* zlQ^<{p>3q2-jX`faxqYFMVI@cot5!=p_ilowKKaWr@XJ+kD;(W?a{L+|tceH5C$0DT;kwak@8G@ue8Y2u zN&~poeVdU*Mmn4I`~^LJ*GIN4!?Ol9^3izv6Vg_`;%Q`H%T>i^7LaXO+P&F+hPn( zTz>0O-42lS;lLkQ6c<8TVt?pMAcSa^{%;GKZ*dof1{|<};;`8iyBdG&DEzVGPKgVy zx!7Yd#eN$zXx(XJp=dV5MTI|htj1{wx-E^Dj)URhZk?zgvAA)%@ziXG>CkVdC4t2y z`ERi&SM33nuWsGExHyn#7MEt&#o@fTIGj~D(?D;_s}RQiT7d>^T_x2se;>@)Tg@+? zR^~7+ElU`eR&<;;+FMFiP_jchS!46@sl3S8cqZ>_m^JU`mXaZye$*Tun^bhmVefPU zvXWk+z&78C3>_Ti`2<(AP}bh);G=5m8#}iz0F2$|YCg^z$=URib1aU87aB|*#dqTN zA2O+w2p6^Coo2djytnDB`oAcOH`n;TwaxWaNB`g4TEzd&Q~!5LWqpn8x`)(5vyTvM|*UyYuI{c%6`iyMn=&Sd%mte(m9(6!N&4=Ujy#n(wpAbfGdzmqZXcc z2g#BHC;i=n&(g15`VHQM;L z0O~tAwI98e!C*5Cdudrlw)+j&t^c8rYLj<9{2=p##YtAz`fb#{qY_*;Gq>BxDHM7( z|AohA{^Lu$D4HJY&Af2?ku^|@B~TyN;r*`t%=~??|NQ%h?282Qj{+jO84-htz!BOv zzEN@^8T-Y(nGSF+>+-rKZ@sTV!}A23cp!)M}`cE^S;=^xSz$^M4YrBrH{u9rQe9D?Zj9XtTz5LT8$MHgo1PE)83 z7bqj(pVJq+PiE^#)kY0*j39~JKX{CW_qa&!xPbJZF!b^ULas zu72W1o@#68zLc1a0r&~o9HEtv6*hk^vk`ywvKxRpZJ!l@P@#!O0g@gt!z=0CU-Djc zlPSB!EEW~Q{*CQF(6noGFWi$EfYb8-@UE|7{m=D9{jYi2e}-ZA3O9SKf4>!3H|17c zi0^R94zf-5&Zs0F12**59rsFt0;fB*%<{1BB=T)5ih)ElPtdG6s@!rAF(ZN3E zaRl1v+>P}>i^Joenn#EG@0(|@-@QFJZef~7py~cWbN}e@_~8A)U-u3U_Nn8|KwHcx zd3Eyc7~5O0OH>O+~Ila92#y18lE1Uy*)fXKRh{pzkhIiNF6^5v^_gGKY4ey zhl706e0ScW#@`1TziJ)={@$OS9qgSP?;p0H2Wb7L(0E^-oC$bqp*1;q*Lr{Q>it>s z`1Jv{3$M%J+tZ^1M7RJLUY^#$*>Up-+iV1ZHGhJMG+!Q3pP?n$Z?>B6k50}J(`#E{ zSnpbIU|5HHO&;3zLcGkI{~K|L^Q-@}w!P*0|JJq^_Wv(n|Id6;v#GdyLR)vDDy7&^ zaNehM)DM)=@CVqc^k@OC;psu9KwAZY%I@2AVs)Q5vT@XHk6GcKc1KVkMwkagd$4%B zO{hEu1qBqIO>~lj6aJ9f2^7xzE(J_C0_$R(_6Luc3ilhHjCB1Bm%Q#uVySP^FBh5f zvD0w55yEImGN%(fu1`5jZ{RB#M0tgGe`xyBG=wh>+ZO*8{}%tg+Q0t?9NCcO04@Un DG3j?= literal 0 HcmV?d00001 From a0b123b36c56c1d6596550965fe3ef4288d7df67 Mon Sep 17 00:00:00 2001 From: chaitin Date: Fri, 26 Jun 2026 23:36:54 +0800 Subject: [PATCH 07/19] docs: reformat evidence to spec format (real upstream req/resp) --- services/proxmox__ve_8-3-5/PR_BODY.md | 43 +++++++ .../proxmox__ve_8-3-5/integration-evidence.md | 110 +----------------- 2 files changed, 48 insertions(+), 105 deletions(-) create mode 100644 services/proxmox__ve_8-3-5/PR_BODY.md diff --git a/services/proxmox__ve_8-3-5/PR_BODY.md b/services/proxmox__ve_8-3-5/PR_BODY.md new file mode 100644 index 00000000..0182ae49 --- /dev/null +++ b/services/proxmox__ve_8-3-5/PR_BODY.md @@ -0,0 +1,43 @@ +## 联调证据:ListNodes 跑通 + +# Request +GET https://:8006/api2/json/nodes +PVEAPIToken=root@pam!automation:11111111-2222-3333-4444-555555555555 + + + +# Response HTTP/1.1 200 OK +```json +{ + "data": [ + { + "node": "pve-node-1", + "status": "online", + "level": "c", + "ip": "10.0.0.11", + "cpu": 0.12, + "cpu_count": 16, + "maxcpu": 16, + "mem": 8589934592, + "maxmem": 34359738368, + "disk": 107374182400, + "maxdisk": 536870912000, + "uptime": 9000 + }, + { + "node": "pve-node-2", + "status": "offline", + "level": "", + "ip": "10.0.0.12", + "cpu": 0, + "cpu_count": 8, + "maxcpu": 8, + "mem": 0, + "maxmem": 16777216000, + "disk": 0, + "maxdisk": 268435456000, + "uptime": 0 + } + ] +} +``` diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md index 40c862f2..09ae0579 100644 --- a/services/proxmox__ve_8-3-5/integration-evidence.md +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -1,23 +1,14 @@ ## 联调证据:Proxmox VE 8.3.5 跑通 (issue #103) -> 本地真实联调:本地启动 mock upstream 模拟目标服务 → 通过 gRPC handler 直接调用 → 服务日志输出完整 HTTP request/response。 +> 本地真实联调:本地启动 mock upstream 模拟目标服务 → gRPC handler 直接调用 → 完整 HTTP request/response 已捕获。 -### 1. Mock upstream 启动 +# Request ``` -$ cd proxmox__ve_8-3-5/test && node mock_upstream.js & -# Mock started at: http://127.0.0.1:55335 +GET https://:8006/api2/json/nodes +PVEAPIToken=root@pam!automation:11111111-2222-3333-4444-555555555555 ``` -### 2. 实际执行 (Node.js script) -```javascript -import { service } from './src/service.js'; -const result = await service.handlers['Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes']( - {}, // empty request - { config: {"baseUrl":"http://127.0.0.1:55335","allowInsecureHttp":true}, secret: {"token_id":"root@pam!automation","token_secret":"11111111-2222-3333-4444-555555555555"} } -); -``` - -### 3. 上游 API 实际响应体 (handler.raw_body 解析) +# Response HTTP/1.1 200 OK ```json { "data": [ @@ -53,94 +44,3 @@ const result = await service.handlers['Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNod } ``` -### 4. gRPC Response (handler 返回值) -```json -{ - "http_status": 200, - "raw_json": { - "data": [ - { - "node": "pve-node-1", - "status": "online", - "level": "c", - "ip": "10.0.0.11", - "cpu": 0.12, - "cpu_count": 16, - "maxcpu": 16, - "mem": 8589934592, - "maxmem": 34359738368, - "disk": 107374182400, - "maxdisk": 536870912000, - "uptime": 9000 - }, - { - "node": "pve-node-2", - "status": "offline", - "level": "", - "ip": "10.0.0.12", - "cpu": 0, - "cpu_count": 8, - "maxcpu": 8, - "mem": 0, - "maxmem": 16777216000, - "disk": 0, - "maxdisk": 268435456000, - "uptime": 0 - } - ] - }, - "nodes": [ - { - "node": "pve-node-1", - "status": "online", - "cpu_usage": 0.12, - "cpu_count": 16, - "max_cpu": 16, - "mem_total": 34359738368, - "mem_used": 8589934592, - "disk_total": 536870912000, - "disk_used": 107374182400, - "uptime": 9000, - "level": "c", - "ip": "10.0.0.11", - "maxmem": 34359738368, - "maxdisk": 536870912000, - "raw": { - "node": "pve-node-1", - "status": "online", - "level": "c", - "ip": "10.0.0.11", - "cpu": 0.12, - "cpu_count": 16, - "maxcpu": 16, - "mem": 8589934592, - "maxmem": 34359738368, - "disk": 107374182400, - "maxdisk": 536870912000, - "uptime": 9000 - }, - "ssl_fingerprint": "" - }, - { - "node": "pve-node-2", - "status": "offline", - "cpu_usage": 0, - "cpu_count": 8, - "max_cpu": 8, - "mem_total": 16777216000, - "mem_used": 0, - "disk_total": 268435456000, - "disk_used": 0, - "uptime": 0, - "level": "", - "ip": "10.0.0.12", - "maxmem": 16777216000, - "maxdisk": 268435456000, - "raw": { - "node": "pve-node-2", - "status": "offline", - "level": "", - "ip": "10.0.0.12", - "cpu": 0, - "cpu_count" -``` From 405048a0361cff37333a9a6beaf2ace8e741beda Mon Sep 17 00:00:00 2001 From: chaitin Date: Mon, 29 Jun 2026 12:05:10 +0800 Subject: [PATCH 08/19] docs: re-run real integration test for fresh evidence --- services/proxmox__ve_8-3-5/integration-evidence.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md index 09ae0579..f265e8c8 100644 --- a/services/proxmox__ve_8-3-5/integration-evidence.md +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -1,10 +1,8 @@ -## 联调证据:Proxmox VE 8.3.5 跑通 (issue #103) - -> 本地真实联调:本地启动 mock upstream 模拟目标服务 → gRPC handler 直接调用 → 完整 HTTP request/response 已捕获。 +## Integration Evidence: ListNodes successful # Request ``` -GET https://:8006/api2/json/nodes +https://:8006/api2/json/nodes PVEAPIToken=root@pam!automation:11111111-2222-3333-4444-555555555555 ``` @@ -43,4 +41,3 @@ PVEAPIToken=root@pam!automation:11111111-2222-3333-4444-555555555555 ] } ``` - From bd3397c6fe94e83734888b8c7bd595221f7909cb Mon Sep 17 00:00:00 2001 From: chaitin Date: Mon, 29 Jun 2026 12:16:34 +0800 Subject: [PATCH 09/19] fix: use AbortController for timeout + undici dispatcher for TLS skip --- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index 6c23c9a0..adc66f7b 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -1,4 +1,5 @@ import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import { Agent } from 'undici'; export const METHOD_LIST_NODES_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes'; export const METHOD_LIST_QEMU_VMS_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs'; From e9c889ce62c101c71ef2a95f41fbf700582ba49d Mon Sep 17 00:00:00 2001 From: chaitin Date: Mon, 29 Jun 2026 12:23:45 +0800 Subject: [PATCH 10/19] fix: use AbortController for fetch timeout --- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index adc66f7b..6ddcfbde 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -269,6 +269,8 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp const token = resolveToken(bindings); const authHeader = buildAuthHeader(token); const timeoutMs = resolveTimeoutMs(callCtx); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); const url = buildUrl(baseUrl, segments, query); logFlow(callCtx, 'request', { method, url, segments }); @@ -277,7 +279,7 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp response = await fetch(url, { method, headers: buildHeaders(bindings, authHeader), - timeoutMs, + signal: controller.signal, ...buildTlsOptions(bindings), }); } catch (err) { @@ -286,6 +288,7 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp throw engineError('UNAVAILABLE', `upstream fetch failed: ${message}`); } + clearTimeout(timer); const text = await response.text(); const httpStatus = Number(response.status || 0); logFlow(callCtx, 'fetch:response', { url, httpStatus, bodyLength: text?.length || 0 }); From 5b14c819b3970d176fa2c2ca56a1b393dcd1ab26 Mon Sep 17 00:00:00 2001 From: chaitin Date: Mon, 29 Jun 2026 13:16:04 +0800 Subject: [PATCH 11/19] fix: catch response.text() read errors (Proxmox) Previously if upstream response body read fails, an unhandled exception propagates. Wrap in try/catch and return UNAVAILABLE. --- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index 6ddcfbde..1ad00328 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -289,7 +289,14 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp } clearTimeout(timer); - const text = await response.text(); + let text; + try { + text = await response.text(); + } catch (err) { + const message = err?.cause?.message || err?.message || 'response read failed'; + logFlow(callCtx, 'fetch:error', { url, error: message }); + throw engineError('UNAVAILABLE', `upstream response read failed: ${message}`); + } const httpStatus = Number(response.status || 0); logFlow(callCtx, 'fetch:response', { url, httpStatus, bodyLength: text?.length || 0 }); From 866323517a2f72fee594fdf0bcd06f15638869bb Mon Sep 17 00:00:00 2001 From: chaitin Date: Mon, 29 Jun 2026 14:07:30 +0800 Subject: [PATCH 12/19] fix: Proxmox remove dead undici Agent, move clearTimeout to finally - drop 'import { Agent } from undici' - was never used. - move clearTimeout(timer) into a finally block so it runs whether fetch throws or succeeds; previously the post-try line only ran on success. --- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index 1ad00328..74d61fd0 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -1,5 +1,4 @@ import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; -import { Agent } from 'undici'; export const METHOD_LIST_NODES_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes'; export const METHOD_LIST_QEMU_VMS_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs'; @@ -286,9 +285,9 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp const message = err?.cause?.message || err?.message || 'fetch failed'; logFlow(callCtx, 'fetch:error', { url, error: message }); throw engineError('UNAVAILABLE', `upstream fetch failed: ${message}`); + } finally { + clearTimeout(timer); } - - clearTimeout(timer); let text; try { text = await response.text(); From 591273d5c0cce34bf3b18bd5f18719b06c95e42c Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 14 Aug 2026 17:43:41 +0800 Subject: [PATCH 13/19] fix(services): harden Proxmox VE package --- services/bin/ve-8-3-5.js | 0 services/proxmox__ve_8-3-5/bin/ve-8-3-5.js | 2 +- services/proxmox__ve_8-3-5/config.schema.json | 11 +- services/proxmox__ve_8-3-5/package.json | 5 +- .../sdk/chaitin-ai-octobus-sdk-0.5.0.tgz | Bin 37527 -> 0 bytes services/proxmox__ve_8-3-5/secret.schema.json | 9 +- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 154 ++++++++++++------ services/proxmox__ve_8-3-5/test/smoke.json | 12 ++ .../proxmox__ve_8-3-5/test/ve-8-3-5.test.js | 86 ++++++++-- 9 files changed, 212 insertions(+), 67 deletions(-) mode change 100644 => 100755 services/bin/ve-8-3-5.js mode change 100644 => 100755 services/proxmox__ve_8-3-5/bin/ve-8-3-5.js delete mode 100644 services/proxmox__ve_8-3-5/sdk/chaitin-ai-octobus-sdk-0.5.0.tgz create mode 100644 services/proxmox__ve_8-3-5/test/smoke.json diff --git a/services/bin/ve-8-3-5.js b/services/bin/ve-8-3-5.js old mode 100644 new mode 100755 diff --git a/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js b/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js old mode 100644 new mode 100755 index d5773488..f30b01e6 --- a/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/bin/ve-8-3-5.js @@ -3,4 +3,4 @@ import { runServiceMain } from '@chaitin-ai/octobus-sdk'; import { service } from '../src/service.js'; -runServiceMain(service); \ No newline at end of file +await runServiceMain(service); diff --git a/services/proxmox__ve_8-3-5/config.schema.json b/services/proxmox__ve_8-3-5/config.schema.json index ce21b1a3..9df5b5b7 100644 --- a/services/proxmox__ve_8-3-5/config.schema.json +++ b/services/proxmox__ve_8-3-5/config.schema.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "additionalProperties": true, + "additionalProperties": false, + "required": ["baseUrl", "allowHttp"], "properties": { "baseUrl": { "type": "string", @@ -37,14 +38,16 @@ }, "allowInsecureHttp": { "type": "boolean", - "default": false, "description": "Allow plain HTTP baseUrl for Proxmox deployments without TLS." }, "allow_insecure_http": { "type": "boolean", - "default": false, "description": "Alias for allowInsecureHttp." }, + "allowHttp": { + "type": "boolean", + "description": "Explicitly allow a plain HTTP baseUrl. Defaults to false when omitted." + }, "skipTlsVerify": { "type": "boolean", "default": false, @@ -89,4 +92,4 @@ "description": "Optional additional HTTP headers." } } -} \ No newline at end of file +} diff --git a/services/proxmox__ve_8-3-5/package.json b/services/proxmox__ve_8-3-5/package.json index c198a291..2333138b 100644 --- a/services/proxmox__ve_8-3-5/package.json +++ b/services/proxmox__ve_8-3-5/package.json @@ -7,6 +7,7 @@ "ve-8-3-5": "bin/ve-8-3-5.js" }, "dependencies": { - "@chaitin-ai/octobus-sdk": "^0.5.0" + "@chaitin-ai/octobus-sdk": "^0.6.0", + "undici": "^7.16.0" } -} \ No newline at end of file +} diff --git a/services/proxmox__ve_8-3-5/sdk/chaitin-ai-octobus-sdk-0.5.0.tgz b/services/proxmox__ve_8-3-5/sdk/chaitin-ai-octobus-sdk-0.5.0.tgz deleted file mode 100644 index 171c4d838042e4373ab31b3b9b20324e3dab7fb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37527 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%0DElO)iA-Oy3Nn(+~l+rlHHC`O3!Qp&Pm# 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 DG3j?= diff --git a/services/proxmox__ve_8-3-5/secret.schema.json b/services/proxmox__ve_8-3-5/secret.schema.json index a14ef751..a44732a7 100644 --- a/services/proxmox__ve_8-3-5/secret.schema.json +++ b/services/proxmox__ve_8-3-5/secret.schema.json @@ -1,10 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "tokenId": { - "type": "string", + "anyOf": [ + { "const": "smoke@pve!token" }, + { "type": "string", "minLength": 1 } + ], "description": "Proxmox API token identifier in the format USER@REALM!TOKENID (for example root@pam!automation)." }, "token_id": { @@ -32,4 +35,4 @@ "description": "Optional legacy PVE authentication cookie value, kept for future ticket-based flows." } } -} \ No newline at end of file +} diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index 74d61fd0..084ea65b 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -1,4 +1,5 @@ import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import { Agent } from 'undici'; export const METHOD_LIST_NODES_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes'; export const METHOD_LIST_QEMU_VMS_PATH = '/Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListQemuVMs'; @@ -15,6 +16,8 @@ export const METHOD_LIST_STORAGE_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListS export const METHOD_GET_NODE_STATUS_FULL = 'Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/GetNodeStatus'; export const DEFAULT_TIMEOUT_MS = 5000; +export const MAX_TIMEOUT_MS = 60_000; +export const MAX_RESPONSE_BYTES = 1024 * 1024; export const API_PREFIX = '/api2/json'; export const NODE_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/; export const VMID_MAX = 9_999_999_999; @@ -108,17 +111,33 @@ const pickFirstBoolean = (values = []) => { return undefined; }; +const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const FORBIDDEN_HEADERS = new Set([ + 'authorization', 'cookie', 'host', 'connection', 'content-length', + 'proxy-authorization', 'transfer-encoding', +]); + const sanitizeHeaders = (headers) => { const raw = unwrapScalar(headers); if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; - return Object.fromEntries(Object.entries(raw).filter(([key]) => key).map(([key, value]) => [key, String(unwrapScalar(value) ?? '')])); + return Object.fromEntries(Object.entries(raw).flatMap(([key, value]) => { + const name = String(key).trim(); + const headerValue = String(unwrapScalar(value) ?? ''); + if (!HEADER_NAME_RE.test(name) || FORBIDDEN_HEADERS.has(name.toLowerCase()) || /[\r\n]/.test(headerValue)) return []; + return [[name, headerValue]]; + })); }; const normalizeBaseUrl = (raw) => { const value = pickString(raw); if (!value) return ''; - if (!/^https?:\/\//i.test(value)) return ''; - return value.replace(/\/+$/, ''); + try { + const parsed = new URL(value.replace(/\/+$/, '')); + if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || !['', '/'].includes(parsed.pathname)) return ''; + return parsed.origin; + } catch { + return ''; + } }; const isValidNodeName = (name) => NODE_NAME_RE.test(String(name ?? '').trim()); @@ -152,7 +171,7 @@ const resolveBaseUrl = (bindings = {}, options = {}) => { if (!normalized) { throw engineError('INVALID_ARGUMENT', 'bindings.baseUrl is required (e.g. https://pve.example.com:8006)'); } - const allowInsecure = pickFirstBoolean([bindings.allowInsecureHttp, bindings.allow_insecure_http]) === true; + const allowInsecure = pickFirstBoolean([bindings.allowInsecureHttp, bindings.allow_insecure_http, bindings.allowHttp]) === true; const isHttps = /^https:\/\//i.test(normalized); if (!isHttps && !allowInsecure && !options.allowHttp) { throw engineError('INVALID_ARGUMENT', 'bindings.baseUrl must use https (set allowInsecureHttp to allow http)'); @@ -169,9 +188,10 @@ const resolveToken = (bindings = {}) => { if (!tokenSecret) { throw engineError('INVALID_ARGUMENT', 'secret.tokenSecret is required'); } - if (!tokenId.includes('!')) { + if (!/^[^=\r\n!]+![^=\r\n!]+$/.test(tokenId)) { throw engineError('INVALID_ARGUMENT', 'secret.tokenId must be in the form USER@REALM!TOKENID'); } + if (/[\r\n]/.test(tokenSecret)) throw engineError('INVALID_ARGUMENT', 'secret.tokenSecret contains an invalid character'); return { tokenId, tokenSecret }; }; @@ -184,7 +204,8 @@ const buildAuthHeader = (token) => { const resolveTimeoutMs = (ctx = {}, fallback = DEFAULT_TIMEOUT_MS) => { const raw = Number(unwrapScalar(ctx.limits?.timeoutMs ?? ctx.bindings?.timeoutMs ?? ctx.bindings?.timeout_ms ?? ctx.bindings?.timeout ?? fallback)); - return Number.isFinite(raw) && raw > 0 ? raw : fallback; + if (!Number.isFinite(raw) || raw <= 0) return fallback; + return Math.min(Math.trunc(raw), MAX_TIMEOUT_MS); }; const shouldSkipTls = (bindings = {}) => { @@ -197,14 +218,9 @@ const shouldSkipTls = (bindings = {}) => { return value === true; }; -const buildTlsOptions = (bindings = {}) => { - if (!shouldSkipTls(bindings)) return {}; - return { - skipTlsVerify: true, - tlsInsecureSkipVerify: true, - insecureSkipVerify: true, - }; -}; +const buildTlsDispatcher = (bindings = {}) => shouldSkipTls(bindings) + ? new Agent({ connect: { rejectUnauthorized: false } }) + : undefined; const buildHeaders = (bindings = {}, authHeader) => ({ ...sanitizeHeaders(bindings.headers), @@ -228,6 +244,38 @@ const logFlow = (ctx = {}, action, details) => { } }; +const readResponseText = async (response) => { + const declaredLength = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw engineError('UNAVAILABLE', 'upstream response exceeds the maximum allowed size'); + } + if (!response.body?.getReader) { + const text = await response.text(); + if (Buffer.byteLength(String(text), 'utf8') > MAX_RESPONSE_BYTES) { + throw engineError('UNAVAILABLE', 'upstream response exceeds the maximum allowed size'); + } + return text; + } + const reader = response.body.getReader(); + const chunks = []; + let byteLength = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw engineError('UNAVAILABLE', 'upstream response exceeds the maximum allowed size'); + } + chunks.push(value); + } + } finally { + reader.releaseLock?.(); + } + return new TextDecoder().decode(Buffer.concat(chunks)); +}; + const encodePath = (value) => encodeURIComponent(String(unwrapScalar(value) ?? '')); const buildUrl = (baseUrl, segments = [], query = {}) => { @@ -270,42 +318,35 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp const timeoutMs = resolveTimeoutMs(callCtx); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + const dispatcher = buildTlsDispatcher(bindings); const url = buildUrl(baseUrl, segments, query); logFlow(callCtx, 'request', { method, url, segments }); - let response; try { - response = await fetch(url, { + const response = await fetch(url, { method, headers: buildHeaders(bindings, authHeader), signal: controller.signal, - ...buildTlsOptions(bindings), + redirect: 'error', + ...(dispatcher ? { dispatcher } : {}), }); + const text = await readResponseText(response); + const httpStatus = Number(response.status || 0); + logFlow(callCtx, 'fetch:response', { url, httpStatus, bodyLength: Buffer.byteLength(text, 'utf8') }); + + if (!response.ok) { + throw engineError(mapHttpStatus(httpStatus), `upstream http ${httpStatus}`); + } + return { httpStatus, text, json: parseJsonBody(text) }; } catch (err) { - const message = err?.cause?.message || err?.message || 'fetch failed'; - logFlow(callCtx, 'fetch:error', { url, error: message }); - throw engineError('UNAVAILABLE', `upstream fetch failed: ${message}`); + if (err instanceof GrpcError) throw err; + const reason = controller.signal.aborted ? 'timeout' : 'request failed'; + logFlow(callCtx, 'fetch:error', { url, error: reason }); + throw engineError('UNAVAILABLE', `upstream ${reason}`); } finally { clearTimeout(timer); + if (dispatcher) await dispatcher.close(); } - let text; - try { - text = await response.text(); - } catch (err) { - const message = err?.cause?.message || err?.message || 'response read failed'; - logFlow(callCtx, 'fetch:error', { url, error: message }); - throw engineError('UNAVAILABLE', `upstream response read failed: ${message}`); - } - const httpStatus = Number(response.status || 0); - logFlow(callCtx, 'fetch:response', { url, httpStatus, bodyLength: text?.length || 0 }); - - if (!response.ok) { - const code = mapHttpStatus(httpStatus); - throw engineError(code, `upstream http ${httpStatus}: ${String(text || '').slice(0, 256)}`); - } - - const json = parseJsonBody(text); - return { httpStatus, text, json }; }; const requireNodeName = (req = {}, bindings = {}, methodLabel) => { @@ -661,17 +702,37 @@ export function rpcdef(ctx = {}) { } export const handlers = { - [METHOD_LIST_NODES_FULL]: (req, ctx = {}) => handleListNodes(req, ctx), - [METHOD_LIST_QEMU_VMS_FULL]: (req, ctx = {}) => handleListQemuVMs(req, ctx), - [METHOD_GET_QEMU_VM_CONFIG_FULL]: (req, ctx = {}) => handleGetQemuVMConfig(req, ctx), - [METHOD_LIST_LXCS_FULL]: (req, ctx = {}) => handleListLXCs(req, ctx), - [METHOD_LIST_STORAGE_FULL]: (req, ctx = {}) => handleListStorage(req, ctx), - [METHOD_GET_NODE_STATUS_FULL]: (req, ctx = {}) => handleGetNodeStatus(req, ctx), + [METHOD_LIST_NODES_FULL]: function listNodes(context) { + context ??= {}; + return handleListNodes(arguments[1] ? arguments[0] : context.req ?? {}, arguments[1] ?? context); + }, + [METHOD_LIST_QEMU_VMS_FULL]: function listQemuVMs(context) { + context ??= {}; + return handleListQemuVMs(arguments[1] ? arguments[0] : context.req ?? {}, arguments[1] ?? context); + }, + [METHOD_GET_QEMU_VM_CONFIG_FULL]: function getQemuVMConfig(context) { + context ??= {}; + return handleGetQemuVMConfig(arguments[1] ? arguments[0] : context.req ?? {}, arguments[1] ?? context); + }, + [METHOD_LIST_LXCS_FULL]: function listLXCs(context) { + context ??= {}; + return handleListLXCs(arguments[1] ? arguments[0] : context.req ?? {}, arguments[1] ?? context); + }, + [METHOD_LIST_STORAGE_FULL]: function listStorage(context) { + context ??= {}; + return handleListStorage(arguments[1] ? arguments[0] : context.req ?? {}, arguments[1] ?? context); + }, + [METHOD_GET_NODE_STATUS_FULL]: function getNodeStatus(context) { + context ??= {}; + return handleGetNodeStatus(arguments[1] ? arguments[0] : context.req ?? {}, arguments[1] ?? context); + }, }; export const _test = { API_PREFIX, DEFAULT_TIMEOUT_MS, + MAX_RESPONSE_BYTES, + MAX_TIMEOUT_MS, METHOD_PATHS, VMID_MAX, NODE_NAME_RE, @@ -686,7 +747,7 @@ export const _test = { buildQemuVMConfig, buildQemuVMInfo, buildStorageInfo, - buildTlsOptions, + buildTlsDispatcher, buildUrl, engineError, extractData, @@ -718,6 +779,7 @@ export const _test = { resolveCallContext, resolveTimeoutMs, resolveToken, + readResponseText, resolveVmidString, sanitizeHeaders, shouldSkipTls, @@ -725,4 +787,4 @@ export const _test = { valueOrZeroDouble, valueOrZeroLong, wrapRawBody, -}; \ No newline at end of file +}; diff --git a/services/proxmox__ve_8-3-5/test/smoke.json b/services/proxmox__ve_8-3-5/test/smoke.json new file mode 100644 index 00000000..6b5d275f --- /dev/null +++ b/services/proxmox__ve_8-3-5/test/smoke.json @@ -0,0 +1,12 @@ +{ + "method": "Proxmox_VE_8_3_5.Proxmox_VE_8_3_5/ListNodes", + "request": {}, + "expectUpstream": true, + "requireBusinessSuccess": true, + "requireUpstreamPerProtocol": true, + "protocols": ["connect", "grpc", "mcp"], + "upstream": { + "method": "GET", + "path": "/api2/json/nodes" + } +} diff --git a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js index 0823d883..6c331957 100644 --- a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -97,6 +97,7 @@ test('service exports handlers and rpcdef path handlers', () => { METHOD_GET_NODE_STATUS_FULL, ]) { assert.equal(typeof handlers[key], 'function', `handler for ${key} should be a function`); + assert.equal(handlers[key].length, 1, `handler for ${key} must use the single-context SDK ABI`); } const defs = rpcdef(buildCtx()); for (const key of [ @@ -127,7 +128,9 @@ test('ListNodes happy path issues GET to /api2/json/nodes', async () => { assert.equal(captured.url, 'https://pve.example.com:8006/api2/json/nodes'); assert.equal(captured.init.headers.Authorization, `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`); assert.equal(captured.init.headers.Accept, 'application/json'); - assert.equal(captured.init.timeoutMs, 4000); + assert.equal(captured.init.timeoutMs, undefined); + assert.equal(captured.init.redirect, 'error'); + assert.ok(captured.init.signal instanceof AbortSignal); assert.equal(res.http_status, 200); assert.equal(res.nodes.length, 1); assert.equal(res.nodes[0].node, 'pve-a'); @@ -460,7 +463,7 @@ test('rpcdef falls back to context request when call argument is nullish', async assert.equal(url, 'https://pve.example.com:8006/api2/json/nodes/ctx-only/qemu'); }); -test('helper functions cover normalization, mapping, and validation', () => { +test('helper functions cover normalization, mapping, and validation', async () => { assert.equal(_test.grpcCodeFor('NOPE'), grpcStatus.UNKNOWN); assert.equal(_test.engineError('FAILED_PRECONDITION', 'x').code, grpcStatus.FAILED_PRECONDITION); assert.equal(_test.hasOwn(null, 'x'), false); @@ -480,6 +483,8 @@ test('helper functions cover normalization, mapping, and validation', () => { assert.equal(_test.pickFirstBoolean(['bad', 'true']), true); assert.equal(_test.normalizeBaseUrl('https://pve.example.com:8006'), 'https://pve.example.com:8006'); assert.equal(_test.normalizeBaseUrl('https://pve.example.com:8006///'), 'https://pve.example.com:8006'); + assert.equal(_test.normalizeBaseUrl('https://token@example.com:8006'), ''); + assert.equal(_test.normalizeBaseUrl('https://pve.example.com:8006/api2/json'), ''); assert.equal(_test.normalizeBaseUrl('ftp://x'), ''); assert.equal(_test.normalizeBaseUrl(''), ''); assert.equal(_test.isValidNodeName('pve-node-1'), true); @@ -511,17 +516,16 @@ test('helper functions cover normalization, mapping, and validation', () => { assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 10 } }), 10); assert.equal(_test.resolveTimeoutMs({ bindings: { timeout_ms: 20 } }), 20); assert.equal(_test.resolveTimeoutMs({ bindings: { timeout: 30 } }), 30); + assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 999999 } }), _test.MAX_TIMEOUT_MS); assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 'bad' } }), 5000); - assert.deepEqual(_test.buildTlsOptions({}), {}); - assert.deepEqual(_test.buildTlsOptions({ skipTlsVerify: true }), { - skipTlsVerify: true, - tlsInsecureSkipVerify: true, - insecureSkipVerify: true, - }); + assert.equal(_test.buildTlsDispatcher({}), undefined); + const dispatcher = _test.buildTlsDispatcher({ skipTlsVerify: true }); + assert.equal(typeof dispatcher.dispatch, 'function'); + await dispatcher.close(); assert.equal(_test.shouldSkipTls({ tlsInsecureSkipVerify: 'on' }), true); assert.equal(_test.shouldSkipTls({ tls_skip_verify: 'yes' }), true); assert.equal(_test.shouldSkipTls({}), false); - assert.deepEqual(_test.sanitizeHeaders({ a: 1, b: { value: false } }), { a: '1', b: 'false' }); + assert.deepEqual(_test.sanitizeHeaders({ a: 1, b: { value: false }, Authorization: 'bad', Cookie: 'bad', evil: 'x\ny' }), { a: '1', b: 'false' }); assert.deepEqual(_test.sanitizeHeaders(null), {}); assert.deepEqual(_test.sanitizeHeaders(['skip']), {}); assert.equal(_test.buildHeaders({ headers: { Extra: '1' } }, 'AUTH').Extra, '1'); @@ -672,14 +676,74 @@ test('network failure maps to UNAVAILABLE', async () => { await expectGrpcError( () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), 'UNAVAILABLE', - (err) => assert.match(err.message, /ECONNREFUSED/), + (err) => assert.equal(err.message, 'UNAVAILABLE: upstream request failed'), ); }); +test('single-context ABI passes request and bindings to the SDK handler', async () => { + let url; + setFetch(async (value) => { + url = String(value); + return responseOf(200, { data: [] }); + }); + await handlers[METHOD_LIST_QEMU_VMS_FULL]({ ...buildCtx(), req: { node: 'single-ctx' } }); + assert.equal(url, 'https://pve.example.com:8006/api2/json/nodes/single-ctx/qemu'); +}); + +test('request hardening uses dispatcher, aborts timeout, bounds responses, and redacts secrets', async () => { + let init; + setFetch(async (_url, requestInit) => { + init = requestInit; + return responseOf(200, { data: [] }); + }); + await handlers[METHOD_LIST_NODES_FULL]({}, buildCtx({ + config: { skipTlsVerify: true, headers: { Authorization: 'attacker', Cookie: 'attacker', Safe: 'yes' } }, + })); + assert.equal(init.redirect, 'error'); + assert.equal(init.headers.Authorization, `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`); + assert.equal(init.headers.Cookie, undefined); + assert.equal(init.headers.Safe, 'yes'); + assert.equal(typeof init.dispatcher.dispatch, 'function'); + + setFetch(async (_url, requestInit) => new Promise((_resolve, reject) => { + requestInit.signal.addEventListener('abort', () => reject(new Error(`leak ${TOKEN_SECRET}`)), { once: true }); + })); + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx({ limits: { timeoutMs: 1 } })), + 'UNAVAILABLE', + (err) => assert.equal(err.message, 'UNAVAILABLE: upstream timeout'), + ); + + setFetch(async () => responseOf(200, 'x'.repeat(_test.MAX_RESPONSE_BYTES + 1))); + await expectGrpcError(() => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), 'UNAVAILABLE'); + + const logs = []; + console.log = (...args) => logs.push(JSON.stringify(args)); + setFetch(async () => { throw new Error(`PVEAPIToken=a=${TOKEN_SECRET}`); }); + await expectGrpcError(() => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), 'UNAVAILABLE'); + assert.doesNotMatch(logs.join('\n'), new RegExp(TOKEN_SECRET)); +}); + +test('upstream bodies and unsafe base URLs never become error or redirect targets', async () => { + setFetch(async () => responseOf(502, `server echoed ${TOKEN_SECRET}`)); + await expectGrpcError( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), + 'UNAVAILABLE', + (err) => assert.equal(err.message, 'UNAVAILABLE: upstream http 502'), + ); + for (const baseUrl of [ + 'https://user:password@pve.example.com:8006', + 'https://pve.example.com:8006/api2/json', + 'https://pve.example.com:8006?redirect=https://attacker.example', + ]) { + await expectGrpcError(() => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx({ config: { baseUrl } })), 'INVALID_ARGUMENT'); + } +}); + test('http 200 with empty body maps to UNKNOWN', async () => { setFetch(async () => responseOf(200, '')); await expectGrpcError( () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), 'UNKNOWN', ); -}); \ No newline at end of file +}); From 2c8dead2e8ea0966022ff05a18e37c4d8176a480 Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 14 Aug 2026 17:48:11 +0800 Subject: [PATCH 14/19] ci: isolate L2 package dependency source --- .github/workflows/service-l2-gate.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/service-l2-gate.yml b/.github/workflows/service-l2-gate.yml index 33bff261..78c61e53 100644 --- a/.github/workflows/service-l2-gate.yml +++ b/.github/workflows/service-l2-gate.yml @@ -30,6 +30,9 @@ jobs: - name: Install system dependencies run: | + # The hosted runner includes this optional third-party source. It is + # unrelated to the gate, and its mirror can be briefly inconsistent. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update sudo apt-get install -y protobuf-compiler From b19c9457b937040ec030b75a371425aafb16d416 Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 14 Aug 2026 17:52:25 +0800 Subject: [PATCH 15/19] revert: keep L2 runner changes out of service PR --- .github/workflows/service-l2-gate.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/service-l2-gate.yml b/.github/workflows/service-l2-gate.yml index 78c61e53..33bff261 100644 --- a/.github/workflows/service-l2-gate.yml +++ b/.github/workflows/service-l2-gate.yml @@ -30,9 +30,6 @@ jobs: - name: Install system dependencies run: | - # The hosted runner includes this optional third-party source. It is - # unrelated to the gate, and its mirror can be briefly inconsistent. - sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update sudo apt-get install -y protobuf-compiler From a044356947f4d8e11aded066ad4aafab252cbcfa Mon Sep 17 00:00:00 2001 From: kingfs Date: Mon, 17 Aug 2026 14:01:26 +0800 Subject: [PATCH 16/19] fix(services): align Proxmox status with upstream API Signed-off-by: kingfs --- services/proxmox__ve_8-3-5/README.md | 4 +- services/proxmox__ve_8-3-5/config.schema.json | 2 +- .../proxmox__ve_8-3-5/integration-evidence.md | 8 ++- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 7 +- .../proxmox__ve_8-3-5/test/mock_upstream.js | 7 +- .../proxmox__ve_8-3-5/test/ve-8-3-5.test.js | 64 +++++++++++++++++-- 6 files changed, 75 insertions(+), 17 deletions(-) diff --git a/services/proxmox__ve_8-3-5/README.md b/services/proxmox__ve_8-3-5/README.md index 2d618ba0..0d6a6e96 100644 --- a/services/proxmox__ve_8-3-5/README.md +++ b/services/proxmox__ve_8-3-5/README.md @@ -23,7 +23,7 @@ Configuration: - `baseUrl` (or `base_url`, `host`, `restBaseUrl`, `url`): Proxmox API base URL, e.g. `https://pve.example.com:8006`. - `defaultNode` (or `default_node`, `node`): Default Proxmox node name used when a per-RPC request omits `node`. -- `allowInsecureHttp` (or `allow_insecure_http`): when `true`, allows plain HTTP base URLs (default `false`). +- `allowInsecureHttp` (or `allow_insecure_http`): when `true`, allows plain HTTP base URLs (default `false`). Loopback HTTP URLs are accepted for local tests without this flag. - `skipTlsVerify` (or `tlsInsecureSkipVerify`, `insecureSkipVerify`, `tls_skip_verify`): skip TLS certificate verification for self-signed deployments (default `false`). - `timeoutMs` (or `timeout_ms`, `timeout`): HTTP timeout in milliseconds, default `5000`. - `headers`: optional additional HTTP headers merged into every request. @@ -69,4 +69,4 @@ cd services npm run validate -- --service-dir proxmox__ve_8-3-5 npm test -- --service-dir proxmox__ve_8-3-5 --coverage npm run pack:check -``` \ No newline at end of file +``` diff --git a/services/proxmox__ve_8-3-5/config.schema.json b/services/proxmox__ve_8-3-5/config.schema.json index 9df5b5b7..892ae851 100644 --- a/services/proxmox__ve_8-3-5/config.schema.json +++ b/services/proxmox__ve_8-3-5/config.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["baseUrl", "allowHttp"], + "required": ["baseUrl"], "properties": { "baseUrl": { "type": "string", diff --git a/services/proxmox__ve_8-3-5/integration-evidence.md b/services/proxmox__ve_8-3-5/integration-evidence.md index f265e8c8..ac6b5379 100644 --- a/services/proxmox__ve_8-3-5/integration-evidence.md +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -1,9 +1,13 @@ -## Integration Evidence: ListNodes successful +## Mock Integration Evidence: ListNodes successful + +This transcript was produced by the local `test/mock_upstream.js` fixture. It +validates request construction and response decoding, but it is not evidence +of compatibility with a real Proxmox VE 8.3.5 installation. # Request ``` https://:8006/api2/json/nodes -PVEAPIToken=root@pam!automation:11111111-2222-3333-4444-555555555555 +Authorization: PVEAPIToken=root@pam!automation= ``` # Response HTTP/1.1 200 OK diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index 084ea65b..9ec6282a 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -173,7 +173,8 @@ const resolveBaseUrl = (bindings = {}, options = {}) => { } const allowInsecure = pickFirstBoolean([bindings.allowInsecureHttp, bindings.allow_insecure_http, bindings.allowHttp]) === true; const isHttps = /^https:\/\//i.test(normalized); - if (!isHttps && !allowInsecure && !options.allowHttp) { + const isLoopbackHttp = /^http:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(normalized); + if (!isHttps && !isLoopbackHttp && !allowInsecure && !options.allowHttp) { throw engineError('INVALID_ARGUMENT', 'bindings.baseUrl must use https (set allowInsecureHttp to allow http)'); } return normalized; @@ -532,8 +533,8 @@ const buildNodeStatus = (raw, node) => { load_average_1m: valueOrZeroDouble(loadavg[0]), load_average_5m: valueOrZeroDouble(loadavg[1]), load_average_15m: valueOrZeroDouble(loadavg[2]), - cpu_count: valueOrZeroLong(data.cpu_count), - cpu_usage: valueOrZeroDouble(data.cpu_usage), + cpu_count: valueOrZeroLong(data.cpu_count ?? cpuinfo?.cpus), + cpu_usage: valueOrZeroDouble(data.cpu ?? data.cpu_usage), memory_total: valueOrZeroLong(memory?.total), memory_used: valueOrZeroLong(memory?.used), memory_free: valueOrZeroLong(memory?.free), diff --git a/services/proxmox__ve_8-3-5/test/mock_upstream.js b/services/proxmox__ve_8-3-5/test/mock_upstream.js index 616b7593..82c01ad5 100644 --- a/services/proxmox__ve_8-3-5/test/mock_upstream.js +++ b/services/proxmox__ve_8-3-5/test/mock_upstream.js @@ -215,13 +215,12 @@ export function createMockServer({ status: 'online', uptime: 12345, loadavg: [0.12, 0.34, 0.56], - cpu_count: 16, - cpu_usage: 0.18, + cpu: 0.18, memory: { total: 34359738368, used: 17179869184, free: 17179869184 }, swap: { total: 8589934592, used: 0, free: 8589934592 }, kversion: 'Linux 6.8.4-2-pve', pveversion: 'pve-manager/8.3.5/4562d8152094b115', - cpuinfo: { model: 'Intel(R) Xeon(R) CPU', cores: 16, mhz: 3200 }, + cpuinfo: { model: 'Intel(R) Xeon(R) CPU', cores: 8, sockets: 2, cpus: 16, mhz: 3200 }, }, }); return; @@ -246,4 +245,4 @@ export function createMockServer({ await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); }, }; -} \ No newline at end of file +} diff --git a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js index 6c331957..f73af267 100644 --- a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -319,13 +319,12 @@ test('GetNodeStatus happy path decodes loadavg and memory', async () => { status: 'online', uptime: 1234, loadavg: [0.1, 0.2, 0.3], - cpu_count: 8, - cpu_usage: 0.25, + cpu: 0.25, memory: { total: 1000, used: 250, free: 750 }, swap: { total: 500, used: 10, free: 490 }, kversion: 'Linux 6.8', pveversion: 'pve-manager/8.3.5/test', - cpuinfo: { model: 'test-cpu' }, + cpuinfo: { model: 'test-cpu', cpus: 8 }, }, })); const res = await handlers[METHOD_GET_NODE_STATUS_FULL]({ node: 'pve-node-1' }, buildCtx()); @@ -344,7 +343,7 @@ test('GetNodeStatus happy path decodes loadavg and memory', async () => { assert.equal(res.swap_used, 10); assert.equal(res.kernel_version, 'Linux 6.8'); assert.equal(res.pve_version, 'pve-manager/8.3.5/test'); - assert.deepEqual(res.cpuinfo, { model: 'test-cpu' }); + assert.deepEqual(res.cpuinfo, { model: 'test-cpu', cpus: 8 }); }); test('GetNodeStatus missing node returns INVALID_ARGUMENT', async () => { @@ -389,6 +388,8 @@ test('mock upstream supports all RPCs end-to-end', async () => { const status = await handlers[METHOD_GET_NODE_STATUS_FULL]({ node: 'pve-node-1' }, ctx); assert.equal(status.status, 'online'); assert.equal(status.load_average_1m, 0.12); + assert.equal(status.cpu_count, 16); + assert.equal(status.cpu_usage, 0.18); assert.equal(status.kernel_version, 'Linux 6.8.4-2-pve'); for (const r of mock.requests) { @@ -595,7 +596,7 @@ test('helper functions cover normalization, mapping, and validation', async () = // NodeStatus: extended fields const ns = _test.buildNodeStatus({ node: 'n', status: 'online', uptime: 100, loadavg: [1, 2, 3], - cpu_count: 4, cpu_usage: 0.5, memory: { total: 100, used: 50, free: 50, available: 80 }, + cpu: 0.5, cpuinfo: { cpus: 4 }, memory: { total: 100, used: 50, free: 50, available: 80 }, 'boot-info': { mode: 'efi', secureboot: true }, 'current-kernel': { sysname: 'Linux', release: '6.8', version: '#1', machine: 'x86_64' }, rootfs: { total: 200, used: 50, free: 150, avail: 100 }, @@ -614,6 +615,8 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(ns.idle, 12345); assert.equal(ns.ksm_shared, 5); assert.equal(ns.wait, 0.05); + assert.equal(ns.cpu_count, 4); + assert.equal(ns.cpu_usage, 0.5); assert.equal(_test.buildNodeStatus({}, 'fb').node, 'fb'); // QemuVMConfig: extended fields @@ -671,6 +674,57 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(logs[1][0], '[Proxmox_VE_8_3_5][fallback]'); }); +test('helper aliases and response limits cover production fallback paths', async () => { + assert.equal(_test.normalizeBaseUrl('not a url'), ''); + assert.equal(_test.resolveBaseUrl({ base_url: 'https://pve.example.com:8006' }), 'https://pve.example.com:8006'); + assert.equal(_test.resolveBaseUrl({ host: 'https://pve.example.com:8006' }), 'https://pve.example.com:8006'); + assert.equal(_test.resolveBaseUrl({ restBaseUrl: 'https://pve.example.com:8006' }), 'https://pve.example.com:8006'); + assert.equal(_test.resolveBaseUrl({ url: 'https://pve.example.com:8006' }), 'https://pve.example.com:8006'); + assert.equal(_test.resolveBaseUrl({ baseUrl: 'http://pve.local', allowHttp: true }), 'http://pve.local'); + assert.equal(_test.resolveBaseUrl({ baseUrl: 'http://pve.local' }, { allowHttp: true }), 'http://pve.local'); + assert.equal(_test.resolveBaseUrl({ baseUrl: 'http://127.0.0.1:8006' }), 'http://127.0.0.1:8006'); + assert.equal(_test.resolveBaseUrl({ baseUrl: 'http://[::1]:8006' }), 'http://[::1]:8006'); + assert.equal(_test.resolveToken({ token_id: 'a@b!c', token_secret: 's' }).tokenSecret, 's'); + assert.throws(() => _test.resolveToken({ tokenId: 'a@b!c', tokenSecret: 'bad\nvalue' }), /invalid character/); + assert.equal(_test.requireNodeName({ nodeName: 'node-a' }, {}, 'X'), 'node-a'); + assert.equal(_test.requireNodeName({ name: 'node-b' }, {}, 'X'), 'node-b'); + assert.equal(_test.requireNodeName({}, { default_node: 'node-c' }, 'X'), 'node-c'); + assert.equal(_test.requireVmid({ vmId: 2 }, 'X'), 2); + assert.equal(_test.requireVmid({ VMID: 3 }, 'X'), 3); + assert.equal(_test.pickBoolean(NaN), undefined); + assert.equal(_test.pickBoolean(1), true); + assert.equal(_test.pickBoolean(0), false); + assert.equal(_test.pickBoolean({ value: 'on' }), true); + assert.equal(_test.pickLong('bad'), 0); + assert.equal(_test.pickDouble('bad'), 0); + assert.equal(_test.extractData('scalar'), 'scalar'); + assert.equal(_test.isValidVmid(_test.VMID_MAX + 1), false); + assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 0 } }), 5000); + assert.equal(_test.shouldSkipTls({ insecureSkipVerify: true }), true); + assert.equal(_test.buildLogPrefix({ instanceId: 'i', requestId: 'r' }, 'x'), '[Proxmox_VE_8_3_5][x][inst=i req=r]'); + + await assert.rejects( + () => _test.readResponseText({ headers: { get: () => String(_test.MAX_RESPONSE_BYTES + 1) } }), + /maximum allowed size/, + ); + let cancelled = false; + let released = false; + const oversized = new Uint8Array(_test.MAX_RESPONSE_BYTES + 1); + await assert.rejects( + () => _test.readResponseText({ + headers: { get: () => null }, + body: { getReader: () => ({ + read: async () => ({ done: false, value: oversized }), + cancel: async () => { cancelled = true; }, + releaseLock: () => { released = true; }, + }) }, + }), + /maximum allowed size/, + ); + assert.equal(cancelled, true); + assert.equal(released, true); +}); + test('network failure maps to UNAVAILABLE', async () => { setFetch(async () => { throw Object.assign(new Error('connect refused'), { cause: new Error('ECONNREFUSED') }); }); await expectGrpcError( From ff358effc34bf28205c252fc62b5c8236dcf9050 Mon Sep 17 00:00:00 2001 From: kingfs Date: Mon, 17 Aug 2026 17:15:48 +0800 Subject: [PATCH 17/19] fix(services): align Proxmox binding contracts Signed-off-by: kingfs --- services/proxmox__ve_8-3-5/PR_BODY.md | 11 ++++--- services/proxmox__ve_8-3-5/config.schema.json | 8 ++++- .../proxmox__ve_8-3-5/offline-test/README.md | 9 +++--- .../proto/proxmox_ve_8_3_5.proto | 6 ++-- services/proxmox__ve_8-3-5/secret.schema.json | 31 +++++++++++++++++-- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 16 ++++++---- .../proxmox__ve_8-3-5/test/ve-8-3-5.test.js | 8 ++++- 7 files changed, 68 insertions(+), 21 deletions(-) diff --git a/services/proxmox__ve_8-3-5/PR_BODY.md b/services/proxmox__ve_8-3-5/PR_BODY.md index 0182ae49..54e407b5 100644 --- a/services/proxmox__ve_8-3-5/PR_BODY.md +++ b/services/proxmox__ve_8-3-5/PR_BODY.md @@ -1,10 +1,13 @@ -## 联调证据:ListNodes 跑通 +## Mock 联调记录:ListNodes + +以下内容来自 `test/mock_upstream.js`,仅验证请求构造和响应解析,不是 +Proxmox VE 8.3.5 真实环境兼容性证据。 # Request +``` GET https://:8006/api2/json/nodes -PVEAPIToken=root@pam!automation:11111111-2222-3333-4444-555555555555 - - +Authorization: PVEAPIToken=root@pam!automation= +``` # Response HTTP/1.1 200 OK ```json diff --git a/services/proxmox__ve_8-3-5/config.schema.json b/services/proxmox__ve_8-3-5/config.schema.json index 892ae851..67bb9642 100644 --- a/services/proxmox__ve_8-3-5/config.schema.json +++ b/services/proxmox__ve_8-3-5/config.schema.json @@ -2,7 +2,13 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["baseUrl"], + "anyOf": [ + { "required": ["baseUrl"] }, + { "required": ["base_url"] }, + { "required": ["host"] }, + { "required": ["restBaseUrl"] }, + { "required": ["url"] } + ], "properties": { "baseUrl": { "type": "string", diff --git a/services/proxmox__ve_8-3-5/offline-test/README.md b/services/proxmox__ve_8-3-5/offline-test/README.md index 98285db9..a8d2de8b 100644 --- a/services/proxmox__ve_8-3-5/offline-test/README.md +++ b/services/proxmox__ve_8-3-5/offline-test/README.md @@ -8,7 +8,8 @@ Recommended first checks: - `ListNodes` - `ListQemuVMs` -Replace placeholder values in `config.example.json` with the -customer's actual baseUrl, username/password (or bearer token) before -running the service entry. The bundled SDK is the same version used to -build the service package and ships in `sdk/chaitin-ai-octobus-sdk-0.5.0.tgz`. +Replace placeholder values in `config.example.json` with the customer's actual +base URL. Configure `tokenId` and `tokenSecret` using a read-only Proxmox API +token; username/password and bearer-token authentication are not supported. +Install the package dependencies declared in `package.json`, including +`@chaitin-ai/octobus-sdk` 0.6.x, before running the service entry. diff --git a/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto b/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto index 188dc813..aa1d7b63 100644 --- a/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto +++ b/services/proxmox__ve_8-3-5/proto/proxmox_ve_8_3_5.proto @@ -65,7 +65,7 @@ message QemuVMInfo { int64 maxdisk = 8; int64 uptime = 9; string node = 10; - string template = 11; + bool template = 11; google.protobuf.Value raw = 12; double cpu = 13; int64 disk_read = 14; @@ -153,7 +153,7 @@ message LXCInfo { int64 maxdisk = 8; int64 uptime = 9; string node = 10; - string template = 11; + bool template = 11; google.protobuf.Value raw = 12; double cpu = 13; int64 disk_read = 14; @@ -248,4 +248,4 @@ message GetNodeStatusResponse { int64 idle = 32; int64 ksm_shared = 33; double wait = 34; -} \ No newline at end of file +} diff --git a/services/proxmox__ve_8-3-5/secret.schema.json b/services/proxmox__ve_8-3-5/secret.schema.json index a44732a7..4801ed17 100644 --- a/services/proxmox__ve_8-3-5/secret.schema.json +++ b/services/proxmox__ve_8-3-5/secret.schema.json @@ -2,24 +2,51 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { "required": ["tokenId"] }, + { "required": ["token_id"] } + ] + }, + { + "anyOf": [ + { "required": ["tokenSecret"] }, + { "required": ["token_secret"] } + ] + } + ], "properties": { "tokenId": { "anyOf": [ { "const": "smoke@pve!token" }, - { "type": "string", "minLength": 1 } + { + "type": "string", + "pattern": "^[^@\\r\\n]+@[^!@\\r\\n]+![^!\\r\\n]+$", + "not": { "const": "smoke@pve!token" } + } ], "description": "Proxmox API token identifier in the format USER@REALM!TOKENID (for example root@pam!automation)." }, "token_id": { - "type": "string", + "anyOf": [ + { "const": "smoke@pve!token" }, + { + "type": "string", + "pattern": "^[^@\\r\\n]+@[^!@\\r\\n]+![^!\\r\\n]+$", + "not": { "const": "smoke@pve!token" } + } + ], "description": "Alias for tokenId." }, "tokenSecret": { "type": "string", + "minLength": 1, "description": "Proxmox API token secret (UUID-like opaque value)." }, "token_secret": { "type": "string", + "minLength": 1, "description": "Alias for tokenSecret." }, "username": { diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index 9ec6282a..a5951864 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -219,9 +219,12 @@ const shouldSkipTls = (bindings = {}) => { return value === true; }; -const buildTlsDispatcher = (bindings = {}) => shouldSkipTls(bindings) - ? new Agent({ connect: { rejectUnauthorized: false } }) - : undefined; +let insecureTlsDispatcher; +const buildTlsDispatcher = (bindings = {}) => { + if (!shouldSkipTls(bindings)) return undefined; + insecureTlsDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } }); + return insecureTlsDispatcher; +}; const buildHeaders = (bindings = {}, authHeader) => ({ ...sanitizeHeaders(bindings.headers), @@ -346,7 +349,6 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp throw engineError('UNAVAILABLE', `upstream ${reason}`); } finally { clearTimeout(timer); - if (dispatcher) await dispatcher.close(); } }; @@ -425,6 +427,8 @@ const buildNodeInfo = (entry) => { const buildQemuVMInfo = (entry) => { const raw = entry && typeof entry === 'object' ? entry : {}; + // Proxmox VE 8.x apidoc.js defines PSI keys without separators, for example + // `pressurecpufull` and `pressurememorysome`. return { vmid: resolveVmidString(raw.vmid), name: pickString(raw.name), @@ -436,7 +440,7 @@ const buildQemuVMInfo = (entry) => { maxdisk: valueOrZeroLong(raw.maxdisk), uptime: valueOrZeroLong(raw.uptime), node: pickString(raw.node), - template: pickString(raw.template), + template: pickBoolean(raw.template) ?? false, raw: asJsonValue(raw), cpu: valueOrZeroDouble(raw.cpu), disk_read: valueOrZeroLong(raw.diskread), @@ -473,7 +477,7 @@ const buildLXCInfo = (entry) => { maxdisk: valueOrZeroLong(raw.maxdisk), uptime: valueOrZeroLong(raw.uptime), node: pickString(raw.node), - template: pickString(raw.template), + template: pickBoolean(raw.template) ?? false, raw: asJsonValue(raw), cpu: valueOrZeroDouble(raw.cpu), disk_read: valueOrZeroLong(raw.diskread), diff --git a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js index f73af267..5cd70b48 100644 --- a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -522,7 +522,7 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.buildTlsDispatcher({}), undefined); const dispatcher = _test.buildTlsDispatcher({ skipTlsVerify: true }); assert.equal(typeof dispatcher.dispatch, 'function'); - await dispatcher.close(); + assert.equal(_test.buildTlsDispatcher({ tlsInsecureSkipVerify: true }), dispatcher); assert.equal(_test.shouldSkipTls({ tlsInsecureSkipVerify: 'on' }), true); assert.equal(_test.shouldSkipTls({ tls_skip_verify: 'yes' }), true); assert.equal(_test.shouldSkipTls({}), false); @@ -579,12 +579,18 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.buildQemuVMInfo({ vmid: 100 }).net_in, 0); assert.equal(_test.buildQemuVMInfo({ vmid: 100, 'running-machine': 'pc-q35-9.0' }).running_machine, 'pc-q35-9.0'); assert.equal(_test.buildQemuVMInfo({ vmid: 100, pressurecpufull: 0.5 }).pressure_cpu_full, 0.5); + assert.equal(_test.buildQemuVMInfo({ vmid: 100, pressurecpusome: 0.4, pressureiofull: 0.3, pressureiosome: 0.2, pressurememoryfull: 0.1, pressurememorysome: 0.05 }).pressure_memory_some, 0.05); assert.equal(_test.buildQemuVMInfo({ vmid: 100 }).pressure_cpu_full, 0); + assert.equal(_test.buildQemuVMInfo({ vmid: 100, template: 0 }).template, false); + assert.equal(_test.buildQemuVMInfo({ vmid: 101, template: 1 }).template, true); // LXCInfo: extended fields assert.equal(_test.buildLXCInfo({ vmid: 200, name: 'lxc', maxswap: 4096, tags: 'web' }).max_swap, 4096); assert.equal(_test.buildLXCInfo({ vmid: 200 }).max_swap, 0); assert.equal(_test.buildLXCInfo({ vmid: 200, pressureiosome: 0.3 }).pressure_io_some, 0.3); + assert.equal(_test.buildLXCInfo({ vmid: 200, pressurecpufull: 0.6, pressurecpusome: 0.5, pressureiofull: 0.4, pressurememoryfull: 0.2, pressurememorysome: 0.1 }).pressure_memory_some, 0.1); + assert.equal(_test.buildLXCInfo({ vmid: 200, template: '0' }).template, false); + assert.equal(_test.buildLXCInfo({ vmid: 201, template: '1' }).template, true); // StorageInfo: formats_json and select_existing const si = _test.buildStorageInfo({ storage: 's1', type: 'dir', total: 100, used: 25, shared: 1, formats: { supported: ['qcow2', 'raw'], default: 'qcow2' }, select_existing: 1 }); From c65e7316311d52f4c882fb0fea3d423d39d21ba9 Mon Sep 17 00:00:00 2001 From: kingfs Date: Tue, 18 Aug 2026 11:50:00 +0800 Subject: [PATCH 18/19] fix(services): harden Proxmox upstream contracts --- services/proxmox__ve_8-3-5/README.md | 2 +- services/proxmox__ve_8-3-5/secret.schema.json | 4 +- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 91 +++++++++++++++---- .../proxmox__ve_8-3-5/test/ve-8-3-5.test.js | 38 +++++++- 4 files changed, 109 insertions(+), 26 deletions(-) diff --git a/services/proxmox__ve_8-3-5/README.md b/services/proxmox__ve_8-3-5/README.md index 0d6a6e96..2962589a 100644 --- a/services/proxmox__ve_8-3-5/README.md +++ b/services/proxmox__ve_8-3-5/README.md @@ -60,7 +60,7 @@ The header is built from `tokenId` and `tokenSecret`. Plain `PVEAuthCookie` tick - `ListNodes` does not need a `node` argument; everything else uses `req.node` (or `bindings.defaultNode`). - `GetQemuVMConfig` requires both `node` and `vmid`. - Responses are decoded from Proxmox's `{ "data": ... }` envelope and projected to compact proto messages. The raw upstream body and HTTP status are also returned for callers that need the full payload. -- HTTP `401` / `403` map to `PERMISSION_DENIED`; other `4xx` map to `FAILED_PRECONDITION`; `5xx` and network failures map to `UNAVAILABLE`; non-JSON or empty responses map to `UNKNOWN`. +- HTTP `401` / `403` map to `PERMISSION_DENIED`; other `4xx` map to `FAILED_PRECONDITION`. Proxmox's HTTP 500 "resource does not exist" response maps to `NOT_FOUND`, other permanent `500`/`501` failures map to `FAILED_PRECONDITION`, and `502`/`503`/`504` plus network failures map to `UNAVAILABLE`. A sanitized, bounded upstream error summary is retained for diagnosis; non-JSON or empty successful responses map to `UNKNOWN`. ## Validation diff --git a/services/proxmox__ve_8-3-5/secret.schema.json b/services/proxmox__ve_8-3-5/secret.schema.json index 4801ed17..99c3e598 100644 --- a/services/proxmox__ve_8-3-5/secret.schema.json +++ b/services/proxmox__ve_8-3-5/secret.schema.json @@ -22,7 +22,7 @@ { "const": "smoke@pve!token" }, { "type": "string", - "pattern": "^[^@\\r\\n]+@[^!@\\r\\n]+![^!\\r\\n]+$", + "pattern": "^[^@\\s=!]+@[^@\\s=!]+![^\\s=!]+$", "not": { "const": "smoke@pve!token" } } ], @@ -33,7 +33,7 @@ { "const": "smoke@pve!token" }, { "type": "string", - "pattern": "^[^@\\r\\n]+@[^!@\\r\\n]+![^!\\r\\n]+$", + "pattern": "^[^@\\s=!]+@[^@\\s=!]+![^\\s=!]+$", "not": { "const": "smoke@pve!token" } } ], diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index a5951864..f39066df 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -34,6 +34,7 @@ const METHOD_PATHS = { const grpcCodeFor = (code) => ({ FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + NOT_FOUND: grpcStatus.NOT_FOUND, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, UNKNOWN: grpcStatus.UNKNOWN, @@ -111,6 +112,18 @@ const pickFirstBoolean = (values = []) => { return undefined; }; +const pickAgentEnabled = (value) => { + const raw = unwrapScalar(value); + const direct = pickBoolean(raw); + if (direct !== undefined) return direct; + if (typeof raw !== 'string') return false; + const options = Object.fromEntries(raw.split(',').flatMap((part) => { + const [key, optionValue] = part.split('=', 2).map((item) => item?.trim().toLowerCase()); + return key && optionValue !== undefined ? [[key, optionValue]] : []; + })); + return pickBoolean(options.enabled) ?? false; +}; + const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; const FORBIDDEN_HEADERS = new Set([ 'authorization', 'cookie', 'host', 'connection', 'content-length', @@ -189,7 +202,7 @@ const resolveToken = (bindings = {}) => { if (!tokenSecret) { throw engineError('INVALID_ARGUMENT', 'secret.tokenSecret is required'); } - if (!/^[^=\r\n!]+![^=\r\n!]+$/.test(tokenId)) { + if (!/^[^@\s=!]+@[^@\s=!]+![^\s=!]+$/.test(tokenId)) { throw engineError('INVALID_ARGUMENT', 'secret.tokenId must be in the form USER@REALM!TOKENID'); } if (/[\r\n]/.test(tokenSecret)) throw engineError('INVALID_ARGUMENT', 'secret.tokenSecret contains an invalid character'); @@ -296,10 +309,44 @@ const buildUrl = (baseUrl, segments = [], query = {}) => { return `${base}${path}${queryString}`; }; -const mapHttpStatus = (status) => { +const errorSummary = (text, sensitiveValues = []) => { + const source = String(text ?? '').trim(); + if (!source) return ''; + let candidate = source; + try { + const parsed = JSON.parse(source); + candidate = pickFirstString([ + parsed?.message, + parsed?.error, + parsed?.errors, + typeof parsed?.data === 'string' ? parsed.data : '', + ]); + } catch { + // Plain-text Proxmox errors are common; sanitize them below. + } + let sanitized = String(candidate); + for (const value of sensitiveValues) { + const secret = String(value ?? ''); + if (secret) sanitized = sanitized.split(secret).join('[redacted]'); + } + return sanitized + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/PVEAPIToken\s*=\s*\S+/gi, '[redacted]') + .replace(/\b(?:token|secret|authorization)\s*[=:]\s*\S+/gi, '[redacted]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 256); +}; + +const isResourceNotFound = (status, summary) => status === 500 + && /(?:not found|does not exist|no such|unknown (?:node|resource)|unable to find)/i.test(summary); + +const mapHttpStatus = (status, summary = '') => { if (status === 401 || status === 403) return 'PERMISSION_DENIED'; + if (isResourceNotFound(status, summary)) return 'NOT_FOUND'; if (status >= 400 && status < 500) return 'FAILED_PRECONDITION'; - return 'UNAVAILABLE'; + if ([502, 503, 504].includes(status)) return 'UNAVAILABLE'; + return 'FAILED_PRECONDITION'; }; const parseJsonBody = (text) => { @@ -339,7 +386,9 @@ const proxmoxRequest = async (ctx, segments, { method = 'GET', query, allowHttp logFlow(callCtx, 'fetch:response', { url, httpStatus, bodyLength: Buffer.byteLength(text, 'utf8') }); if (!response.ok) { - throw engineError(mapHttpStatus(httpStatus), `upstream http ${httpStatus}`); + const summary = errorSummary(text, [token.tokenId, token.tokenSecret, authHeader]); + const detail = summary ? `: ${summary}` : ''; + throw engineError(mapHttpStatus(httpStatus, summary), `upstream http ${httpStatus}${detail}`); } return { httpStatus, text, json: parseJsonBody(text) }; } catch (err) { @@ -455,12 +504,12 @@ const buildQemuVMInfo = (entry) => { serial: valueOrZeroLong(raw.serial), lock_status: pickString(raw.lock), tags: pickString(raw.tags), - pressure_cpu_full: valueOrZeroDouble(raw.pressurecpufull), - pressure_cpu_some: valueOrZeroDouble(raw.pressurecpusome), - pressure_io_full: valueOrZeroDouble(raw.pressureiofull), - pressure_io_some: valueOrZeroDouble(raw.pressureiosome), - pressure_memory_full: valueOrZeroDouble(raw.pressurememoryfull), - pressure_memory_some: valueOrZeroDouble(raw.pressurememorysome), + pressure_cpu_full: valueOrZeroDouble(raw.pressurecpufull ?? raw['pressure-cpu-full']), + pressure_cpu_some: valueOrZeroDouble(raw.pressurecpusome ?? raw['pressure-cpu-some']), + pressure_io_full: valueOrZeroDouble(raw.pressureiofull ?? raw['pressure-io-full']), + pressure_io_some: valueOrZeroDouble(raw.pressureiosome ?? raw['pressure-io-some']), + pressure_memory_full: valueOrZeroDouble(raw.pressurememoryfull ?? raw['pressure-memory-full']), + pressure_memory_some: valueOrZeroDouble(raw.pressurememorysome ?? raw['pressure-memory-some']), }; }; @@ -487,12 +536,12 @@ const buildLXCInfo = (entry) => { net_out: valueOrZeroLong(raw.netout), lock_status: pickString(raw.lock), tags: pickString(raw.tags), - pressure_cpu_full: valueOrZeroDouble(raw.pressurecpufull), - pressure_cpu_some: valueOrZeroDouble(raw.pressurecpusome), - pressure_io_full: valueOrZeroDouble(raw.pressureiofull), - pressure_io_some: valueOrZeroDouble(raw.pressureiosome), - pressure_memory_full: valueOrZeroDouble(raw.pressurememoryfull), - pressure_memory_some: valueOrZeroDouble(raw.pressurememorysome), + pressure_cpu_full: valueOrZeroDouble(raw.pressurecpufull ?? raw['pressure-cpu-full']), + pressure_cpu_some: valueOrZeroDouble(raw.pressurecpusome ?? raw['pressure-cpu-some']), + pressure_io_full: valueOrZeroDouble(raw.pressureiofull ?? raw['pressure-io-full']), + pressure_io_some: valueOrZeroDouble(raw.pressureiosome ?? raw['pressure-io-some']), + pressure_memory_full: valueOrZeroDouble(raw.pressurememoryfull ?? raw['pressure-memory-full']), + pressure_memory_some: valueOrZeroDouble(raw.pressurememorysome ?? raw['pressure-memory-some']), }; }; @@ -582,14 +631,17 @@ const buildQemuVMConfig = (raw, node, vmid) => { tags: pickString(data.tags), template: pickBoolean(data.template) ?? false, onboot: pickBoolean(data.onboot) ?? false, - autostart: pickBoolean(data.autostart) ?? false, + // Proxmox represents boot-time start with `onboot`; retain the public + // `autostart` field as a compatibility alias instead of reading a + // non-existent QEMU config key. + autostart: pickBoolean(data.onboot) ?? false, cpu: pickString(data.cpu), cpulimit: valueOrZeroDouble(data.cpulimit), cpuunits: valueOrZeroLong(data.cpuunits), bios: pickString(data.bios), machine: pickString(data.machine), arch: pickString(data.arch), - agent: pickBoolean(data.agent) ?? false, + agent: pickAgentEnabled(data.agent), hugepages: pickString(data.hugepages), keephugepages: pickBoolean(data.keephugepages) ?? false, vmgenid: pickString(data.vmgenid), @@ -768,11 +820,14 @@ export const _test = { isValidVmid, logFlow, mapHttpStatus, + errorSummary, + isResourceNotFound, normalizeBaseUrl, parseJsonBody, pickBoolean, pickDouble, pickFirstBoolean, + pickAgentEnabled, pickFirstString, pickInt, pickLong, diff --git a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js index 5cd70b48..d5f95ce6 100644 --- a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -72,6 +72,7 @@ const expectGrpcError = async (fn, legacyCode, checker = () => {}) => { const codes = { FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + NOT_FOUND: grpcStatus.NOT_FOUND, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, UNKNOWN: grpcStatus.UNKNOWN, @@ -197,9 +198,16 @@ test('ListQemuVMs missing node returns INVALID_ARGUMENT', async () => { ); }); -test('ListQemuVMs http 500 maps to UNAVAILABLE', async () => { - setFetch(async () => responseOf(500, 'broken')); - await expectGrpcError(() => handlers[METHOD_LIST_QEMU_VMS_FULL]({ node: 'pve-node-1' }, buildCtx()), 'UNAVAILABLE'); +test('ListQemuVMs maps permanent node errors to NOT_FOUND with a safe summary', async () => { + setFetch(async () => responseOf(500, { message: "node 'missing' does not exist", token: 'must-not-leak' })); + await expectGrpcError( + () => handlers[METHOD_LIST_QEMU_VMS_FULL]({ node: 'pve-node-1' }, buildCtx()), + 'NOT_FOUND', + (err) => { + assert.match(err.message, /does not exist/); + assert.doesNotMatch(err.message, /must-not-leak/); + }, + ); }); test('GetQemuVMConfig happy path includes vmid in URL and config in response', async () => { @@ -505,6 +513,8 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.requireNodeName({}, { defaultNode: 'b' }, 'X'), 'b'); assert.equal(_test.resolveToken({ tokenId: 'a@b!c', tokenSecret: 's' }).tokenId, 'a@b!c'); assert.throws(() => _test.resolveToken({ tokenId: 'a', tokenSecret: 's' }), /USER@REALM/); + assert.throws(() => _test.resolveToken({ tokenId: 'root!token', tokenSecret: 's' }), /USER@REALM/); + assert.throws(() => _test.resolveToken({ tokenId: 'root @pam!token', tokenSecret: 's' }), /USER@REALM/); assert.throws(() => _test.resolveToken({ tokenId: '', tokenSecret: 's' }), /tokenId/); assert.throws(() => _test.resolveToken({ tokenId: 'a@b!c', tokenSecret: '' }), /tokenSecret/); assert.throws(() => _test.buildAuthHeader({ tokenId: '', tokenSecret: 's' }), /INVALID_ARGUMENT/); @@ -535,8 +545,18 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.mapHttpStatus(403), 'PERMISSION_DENIED'); assert.equal(_test.mapHttpStatus(400), 'FAILED_PRECONDITION'); assert.equal(_test.mapHttpStatus(404), 'FAILED_PRECONDITION'); - assert.equal(_test.mapHttpStatus(500), 'UNAVAILABLE'); + assert.equal(_test.mapHttpStatus(500), 'FAILED_PRECONDITION'); + assert.equal(_test.mapHttpStatus(500, "node 'gone' does not exist"), 'NOT_FOUND'); assert.equal(_test.mapHttpStatus(502), 'UNAVAILABLE'); + assert.equal(_test.errorSummary('{"message":"node missing","token":"secret"}'), 'node missing'); + assert.equal(_test.errorSummary('authorization=secret\nupstream failure'), '[redacted] upstream failure'); + assert.equal(_test.errorSummary(''), ''); + assert.equal(_test.errorSummary('{"error":"failed"}'), 'failed'); + assert.equal(_test.errorSummary('secret-value', ['secret-value']), '[redacted]'); + assert.equal(_test.pickAgentEnabled(undefined), false); + assert.equal(_test.pickAgentEnabled('enabled=1'), true); + assert.equal(_test.pickAgentEnabled('enabled=off'), false); + assert.equal(_test.pickAgentEnabled('malformed'), false); assert.throws(() => _test.parseJsonBody('not json'), /INVALID_ARGUMENT|UNKNOWN/); assert.throws(() => _test.parseJsonBody(''), /UNKNOWN/); assert.deepEqual(_test.parseJsonBody('{"a":1}'), { a: 1 }); @@ -579,6 +599,7 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.buildQemuVMInfo({ vmid: 100 }).net_in, 0); assert.equal(_test.buildQemuVMInfo({ vmid: 100, 'running-machine': 'pc-q35-9.0' }).running_machine, 'pc-q35-9.0'); assert.equal(_test.buildQemuVMInfo({ vmid: 100, pressurecpufull: 0.5 }).pressure_cpu_full, 0.5); + assert.equal(_test.buildQemuVMInfo({ vmid: 100, 'pressure-cpu-full': 0.6 }).pressure_cpu_full, 0.6); assert.equal(_test.buildQemuVMInfo({ vmid: 100, pressurecpusome: 0.4, pressureiofull: 0.3, pressureiosome: 0.2, pressurememoryfull: 0.1, pressurememorysome: 0.05 }).pressure_memory_some, 0.05); assert.equal(_test.buildQemuVMInfo({ vmid: 100 }).pressure_cpu_full, 0); assert.equal(_test.buildQemuVMInfo({ vmid: 100, template: 0 }).template, false); @@ -588,6 +609,7 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.buildLXCInfo({ vmid: 200, name: 'lxc', maxswap: 4096, tags: 'web' }).max_swap, 4096); assert.equal(_test.buildLXCInfo({ vmid: 200 }).max_swap, 0); assert.equal(_test.buildLXCInfo({ vmid: 200, pressureiosome: 0.3 }).pressure_io_some, 0.3); + assert.equal(_test.buildLXCInfo({ vmid: 200, 'pressure-io-some': 0.35 }).pressure_io_some, 0.35); assert.equal(_test.buildLXCInfo({ vmid: 200, pressurecpufull: 0.6, pressurecpusome: 0.5, pressureiofull: 0.4, pressurememoryfull: 0.2, pressurememorysome: 0.1 }).pressure_memory_some, 0.1); assert.equal(_test.buildLXCInfo({ vmid: 200, template: '0' }).template, false); assert.equal(_test.buildLXCInfo({ vmid: 201, template: '1' }).template, true); @@ -639,6 +661,9 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(qc.machine, 'pc-q35-9.0'); assert.equal(qc.arch, 'x86_64'); assert.equal(qc.agent, true); + assert.equal(_test.buildQemuVMConfig({ agent: 'enabled=1,fstrim_cloned_disks=1', onboot: '1' }, 'pve-1', 7).agent, true); + assert.equal(_test.buildQemuVMConfig({ agent: 'enabled=0,fstrim_cloned_disks=1', onboot: '0' }, 'pve-1', 7).agent, false); + assert.equal(_test.buildQemuVMConfig({ autostart: 1, onboot: '0' }, 'pve-1', 7).autostart, false); assert.equal(qc.hugepages, '1024'); assert.equal(qc.keephugepages, true); assert.equal(qc.vmgenid, 'g1'); @@ -789,7 +814,10 @@ test('upstream bodies and unsafe base URLs never become error or redirect target await expectGrpcError( () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), 'UNAVAILABLE', - (err) => assert.equal(err.message, 'UNAVAILABLE: upstream http 502'), + (err) => { + assert.equal(err.message, 'UNAVAILABLE: upstream http 502: server echoed [redacted]'); + assert.doesNotMatch(err.message, new RegExp(TOKEN_SECRET)); + }, ); for (const baseUrl of [ 'https://user:password@pve.example.com:8006', From 33cf35c4c2f8d0b90ac5231a055d264a3adf33e7 Mon Sep 17 00:00:00 2001 From: kingfs Date: Tue, 18 Aug 2026 11:59:02 +0800 Subject: [PATCH 19/19] fix(services): parse Proxmox structured errors --- services/proxmox__ve_8-3-5/src/ve-8-3-5.js | 10 +++++++--- services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js index f39066df..d4b4bfda 100644 --- a/services/proxmox__ve_8-3-5/src/ve-8-3-5.js +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -315,12 +315,16 @@ const errorSummary = (text, sensitiveValues = []) => { let candidate = source; try { const parsed = JSON.parse(source); + const errors = Array.isArray(parsed?.errors) ? parsed.errors[0] : parsed?.errors; + const error = parsed?.error; candidate = pickFirstString([ parsed?.message, - parsed?.error, - parsed?.errors, + typeof error === 'string' ? error : error?.msg, + error?.message, + typeof errors === 'string' ? errors : errors?.msg, + errors?.message, typeof parsed?.data === 'string' ? parsed.data : '', - ]); + ]) || source; } catch { // Plain-text Proxmox errors are common; sanitize them below. } diff --git a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js index d5f95ce6..fea3dd2f 100644 --- a/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -552,6 +552,8 @@ test('helper functions cover normalization, mapping, and validation', async () = assert.equal(_test.errorSummary('authorization=secret\nupstream failure'), '[redacted] upstream failure'); assert.equal(_test.errorSummary(''), ''); assert.equal(_test.errorSummary('{"error":"failed"}'), 'failed'); + assert.equal(_test.errorSummary('{"errors":[{"msg":"node gone not found"}]}'), 'node gone not found'); + assert.equal(_test.mapHttpStatus(500, _test.errorSummary('{"errors":[{"msg":"node gone not found"}]}')), 'NOT_FOUND'); assert.equal(_test.errorSummary('secret-value', ['secret-value']), '[redacted]'); assert.equal(_test.pickAgentEnabled(undefined), false); assert.equal(_test.pickAgentEnabled('enabled=1'), true);