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 100755 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/PR_BODY.md b/services/proxmox__ve_8-3-5/PR_BODY.md new file mode 100644 index 00000000..54e407b5 --- /dev/null +++ b/services/proxmox__ve_8-3-5/PR_BODY.md @@ -0,0 +1,46 @@ +## Mock 联调记录:ListNodes + +以下内容来自 `test/mock_upstream.js`,仅验证请求构造和响应解析,不是 +Proxmox VE 8.3.5 真实环境兼容性证据。 + +# Request +``` +GET https://:8006/api2/json/nodes +Authorization: PVEAPIToken=root@pam!automation= +``` + +# 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/README.md b/services/proxmox__ve_8-3-5/README.md new file mode 100644 index 00000000..2962589a --- /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`). 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. + +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`. 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 + +```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 +``` 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 100755 index 00000000..f30b01e6 --- /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'; + +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 new file mode 100644 index 00000000..67bb9642 --- /dev/null +++ b/services/proxmox__ve_8-3-5/config.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "anyOf": [ + { "required": ["baseUrl"] }, + { "required": ["base_url"] }, + { "required": ["host"] }, + { "required": ["restBaseUrl"] }, + { "required": ["url"] } + ], + "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", + "description": "Allow plain HTTP baseUrl for Proxmox deployments without TLS." + }, + "allow_insecure_http": { + "type": "boolean", + "description": "Alias for allowInsecureHttp." + }, + "allowHttp": { + "type": "boolean", + "description": "Explicitly allow a plain HTTP baseUrl. Defaults to false when omitted." + }, + "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." + } + } +} 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..ac6b5379 --- /dev/null +++ b/services/proxmox__ve_8-3-5/integration-evidence.md @@ -0,0 +1,47 @@ +## 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 +Authorization: PVEAPIToken=root@pam!automation= +``` + +# 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/offline-test/README.md b/services/proxmox__ve_8-3-5/offline-test/README.md new file mode 100644 index 00000000..a8d2de8b --- /dev/null +++ b/services/proxmox__ve_8-3-5/offline-test/README.md @@ -0,0 +1,15 @@ +# 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 +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/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/package.json b/services/proxmox__ve_8-3-5/package.json new file mode 100644 index 00000000..2333138b --- /dev/null +++ b/services/proxmox__ve_8-3-5/package.json @@ -0,0 +1,13 @@ +{ + "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.6.0", + "undici": "^7.16.0" + } +} 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..aa1d7b63 --- /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; + bool 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; + bool 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; +} 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..99c3e598 --- /dev/null +++ b/services/proxmox__ve_8-3-5/secret.schema.json @@ -0,0 +1,65 @@ +{ + "$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", + "pattern": "^[^@\\s=!]+@[^@\\s=!]+![^\\s=!]+$", + "not": { "const": "smoke@pve!token" } + } + ], + "description": "Proxmox API token identifier in the format USER@REALM!TOKENID (for example root@pam!automation)." + }, + "token_id": { + "anyOf": [ + { "const": "smoke@pve!token" }, + { + "type": "string", + "pattern": "^[^@\\s=!]+@[^@\\s=!]+![^\\s=!]+$", + "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": { + "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." + } + } +} 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..d4b4bfda --- /dev/null +++ b/services/proxmox__ve_8-3-5/src/ve-8-3-5.js @@ -0,0 +1,854 @@ +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'; +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 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; + +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, + NOT_FOUND: grpcStatus.NOT_FOUND, + 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 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', + '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).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 ''; + 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()); + +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, bindings.allowHttp]) === true; + const isHttps = /^https:\/\//i.test(normalized); + 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; +}; + +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 (!/^[^@\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'); + 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)); + if (!Number.isFinite(raw) || raw <= 0) return fallback; + return Math.min(Math.trunc(raw), MAX_TIMEOUT_MS); +}; + +const shouldSkipTls = (bindings = {}) => { + const value = pickFirstBoolean([ + bindings.skipTlsVerify, + bindings.tlsInsecureSkipVerify, + bindings.insecureSkipVerify, + bindings.tls_skip_verify, + ]); + return value === true; +}; + +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), + 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 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 = {}) => { + 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 errorSummary = (text, sensitiveValues = []) => { + const source = String(text ?? '').trim(); + if (!source) return ''; + 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, + 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. + } + 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'; + if ([502, 503, 504].includes(status)) return 'UNAVAILABLE'; + return 'FAILED_PRECONDITION'; +}; + +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 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 }); + + try { + const response = await fetch(url, { + method, + headers: buildHeaders(bindings, authHeader), + signal: controller.signal, + 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) { + 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) { + 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); + } +}; + +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 : {}; + // 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), + 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: pickBoolean(raw.template) ?? false, + 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 ?? 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']), + }; +}; + +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: pickBoolean(raw.template) ?? false, + 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 ?? 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']), + }; +}; + +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 ?? 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), + 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, + // 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: pickAgentEnabled(data.agent), + 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]: 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, + arrayOrEmpty, + asJsonValue, + buildAuthHeader, + buildHeaders, + buildLXCInfo, + buildLogPrefix, + buildNodeInfo, + buildNodeStatus, + buildQemuVMConfig, + buildQemuVMInfo, + buildStorageInfo, + buildTlsDispatcher, + buildUrl, + engineError, + extractData, + grpcCodeFor, + handleGetNodeStatus, + handleGetQemuVMConfig, + handleListLXCs, + handleListNodes, + handleListQemuVMs, + handleListStorage, + hasOwn, + isValidNodeName, + isValidVmid, + logFlow, + mapHttpStatus, + errorSummary, + isResourceNotFound, + normalizeBaseUrl, + parseJsonBody, + pickBoolean, + pickDouble, + pickFirstBoolean, + pickAgentEnabled, + pickFirstString, + pickInt, + pickLong, + pickString, + proxmoxRequest, + requireNodeName, + requireVmid, + resolveBaseUrl, + resolveCallContext, + resolveTimeoutMs, + resolveToken, + readResponseText, + resolveVmidString, + sanitizeHeaders, + shouldSkipTls, + unwrapScalar, + valueOrZeroDouble, + valueOrZeroLong, + wrapRawBody, +}; 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..82c01ad5 --- /dev/null +++ b/services/proxmox__ve_8-3-5/test/mock_upstream.js @@ -0,0 +1,248 @@ +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: 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: 8, sockets: 2, cpus: 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()))); + }, + }; +} 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 new file mode 100644 index 00000000..fea3dd2f --- /dev/null +++ b/services/proxmox__ve_8-3-5/test/ve-8-3-5.test.js @@ -0,0 +1,839 @@ +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, + NOT_FOUND: grpcStatus.NOT_FOUND, + 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`); + assert.equal(handlers[key].length, 1, `handler for ${key} must use the single-context SDK ABI`); + } + 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, 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'); + 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 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 () => { + 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: 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', cpus: 8 }, + }, + })); + 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', cpus: 8 }); +}); + +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.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) { + 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', 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); + 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('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); + 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: '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/); + 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: 999999 } }), _test.MAX_TIMEOUT_MS); + assert.equal(_test.resolveTimeoutMs({ limits: { timeoutMs: 'bad' } }), 5000); + assert.equal(_test.buildTlsDispatcher({}), undefined); + const dispatcher = _test.buildTlsDispatcher({ skipTlsVerify: true }); + assert.equal(typeof dispatcher.dispatch, 'function'); + 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); + 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'); + 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), '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('{"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); + 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 }); + 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.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); + 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, '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); + + // 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: 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 }, + 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(ns.cpu_count, 4); + assert.equal(ns.cpu_usage, 0.5); + 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(_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'); + 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('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( + () => handlers[METHOD_LIST_NODES_FULL]({}, buildCtx()), + 'UNAVAILABLE', + (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: server echoed [redacted]'); + assert.doesNotMatch(err.message, new RegExp(TOKEN_SECRET)); + }, + ); + 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', + ); +});