From f0d45fe9c74f4ada3281bfa8921995a2dd5d5b37 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 20 Feb 2026 13:38:44 +0000 Subject: [PATCH] feat: add CPU-aware host filtering and host-info detection utility - Add CpuMfg, CpuArch, and CpuFeature enums for CPU type specification - Add cpu_mfg, cpu_arch, cpu_features columns to vm_host, vm_template, vm_custom_pricing, and vm_custom_template tables - Add lnvps_host_util crate with lnvps-host-info binary for detecting CPU/GPU features on hosts via SSH - Add SSH credentials (ssh_user, ssh_key) to vm_host for remote execution - Add CommaSeparated type for storing feature lists in database - Update capacity filtering to match hosts by CPU requirements - Cross-compile host-info binary for x86_64 and arm64 in Dockerfile - Update admin API with CPU fields for templates, pricing, and hosts - Update user API documentation with CPU feature enums --- .github/workflows/build.yml | 49 +- ADMIN_API_ENDPOINTS.md | 59 +- API_CHANGELOG.md | 22 + API_DOCUMENTATION.md | 23 + Cargo.lock | 172 ++++- Cargo.toml | 1 + lnvps_api/Dockerfile | 9 +- lnvps_api/src/api/ip_space.rs | 2 - lnvps_api/src/api/model.rs | 32 +- lnvps_api/src/api/routes.rs | 8 +- lnvps_api/src/api/subscriptions.rs | 4 +- lnvps_api/src/data_migration/mod.rs | 5 + .../src/data_migration/ssh_key_migration.rs | 81 ++ lnvps_api/src/dvm/lnvps.rs | 16 +- lnvps_api/src/host/libvirt.rs | 4 +- lnvps_api/src/host/mod.rs | 10 +- lnvps_api/src/host/proxmox.rs | 15 +- lnvps_api/src/mocks.rs | 2 +- .../provisioner/integration_retry_tests.rs | 6 +- lnvps_api/src/provisioner/lnvps.rs | 405 +++++++++- lnvps_api/src/router/mod.rs | 1 + lnvps_api/src/router/ovh.rs | 1 - lnvps_api/src/ssh_client.rs | 39 + lnvps_api/src/worker.rs | 203 ++++- lnvps_api_admin/src/admin/custom_pricing.rs | 6 + lnvps_api_admin/src/admin/hosts.rs | 20 + lnvps_api_admin/src/admin/model.rs | 30 +- lnvps_api_admin/src/admin/vm_templates.rs | 3 + lnvps_api_admin/src/bin/generate_demo_data.rs | 77 +- lnvps_api_admin/src/lib.rs | 2 - lnvps_api_common/src/capacity.rs | 250 +++++- lnvps_api_common/src/mock.rs | 60 +- lnvps_api_common/src/model.rs | 91 ++- lnvps_api_common/src/nip98.rs | 4 +- lnvps_api_common/src/pricing.rs | 493 ++++++++++-- lnvps_api_common/src/status.rs | 1 - .../migrations/20260219000000_cpu_type.sql | 28 + lnvps_db/src/comma_separated.rs | 210 ++++++ lnvps_db/src/lib.rs | 2 + lnvps_db/src/model.rs | 408 +++++++++- lnvps_db/src/mysql.rs | 138 ++-- lnvps_host_util/Cargo.toml | 21 + lnvps_host_util/Dockerfile | 12 + lnvps_host_util/src/gpu.rs | 236 ++++++ lnvps_host_util/src/main.rs | 710 ++++++++++++++++++ 45 files changed, 3732 insertions(+), 239 deletions(-) create mode 100644 lnvps_api/src/data_migration/ssh_key_migration.rs create mode 100644 lnvps_db/migrations/20260219000000_cpu_type.sql create mode 100644 lnvps_db/src/comma_separated.rs create mode 100644 lnvps_host_util/Cargo.toml create mode 100644 lnvps_host_util/Dockerfile create mode 100644 lnvps_host_util/src/gpu.rs create mode 100644 lnvps_host_util/src/main.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a57cd3a..1c6a87d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,9 +6,54 @@ on: - master pull_request: +env: + REGISTRY: registry.v0l.io + HOST_INFO_IMAGE: registry.v0l.io/lnvps-host-info + jobs: + # Build host-info for multiple architectures first + build-host-info: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + tag_suffix: amd64 + - platform: linux/arm64 + tag_suffix: arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Registry + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: registry + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push lnvps-host-info (${{ matrix.tag_suffix }}) + uses: docker/build-push-action@v5 + with: + context: . + file: lnvps_host_util/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + tags: ${{ env.HOST_INFO_IMAGE }}:latest-${{ matrix.tag_suffix }} + + # Build main images after host-info is ready build: runs-on: ubuntu-latest + needs: build-host-info strategy: fail-fast: false matrix: @@ -37,7 +82,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' uses: docker/login-action@v3 with: - registry: registry.v0l.io + registry: ${{ env.REGISTRY }} username: registry password: ${{ secrets.REGISTRY_TOKEN }} @@ -48,3 +93,5 @@ jobs: file: ${{ matrix.file }} push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} tags: ${{ matrix.image }} + build-args: | + HOST_INFO_IMAGE=${{ env.HOST_INFO_IMAGE }} diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 85e6f036..691705a1 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -22,6 +22,15 @@ Admin API request/response format reference for LLM consumption. **SubscriptionPaymentType**: `"purchase"`, `"renewal"` **SubscriptionType**: `"ip_range"`, `"asn_sponsoring"`, `"dns_hosting"` **InternetRegistry**: `"arin"`, `"ripe"`, `"apnic"`, `"lacnic"`, `"afrinic"` +**CpuMfg**: `"unknown"`, `"intel"`, `"amd"`, `"apple"`, `"nvidia"`, `"arm"` +**CpuArch**: `"unknown"`, `"x86_64"`, `"arm64"` +**CpuFeature**: `"SSE"`, `"SSE2"`, `"SSE3"`, `"SSSE3"`, `"SSE4_1"`, `"SSE4_2"`, `"AVX"`, `"AVX2"`, `"FMA"`, `"F16C"`, +`"AVX512F"`, `"AVX512VNNI"`, `"AVX512BF16"`, `"AVXVNNI"`, `"NEON"`, `"SVE"`, `"SVE2"`, `"AES"`, `"SHA"`, `"SHA512"`, +`"PCLMULQDQ"`, `"RNG"`, `"GFNI"`, `"VAES"`, `"VPCLMULQDQ"`, `"VMX"`, `"NestedVirt"`, `"AMX"`, `"SME"`, `"SGX"`, `"SEV"`, +`"TDX"`, `"EncodeH264"`, `"EncodeHEVC"`, `"EncodeAV1"`, `"EncodeVP9"`, `"EncodeJPEG"`, `"DecodeH264"`, `"DecodeHEVC"`, +`"DecodeAV1"`, `"DecodeVP9"`, `"DecodeJPEG"`, `"DecodeMPEG2"`, `"DecodeVC1"`, `"VideoScaling"`, `"VideoDeinterlace"`, +`"VideoCSC"`, `"VideoComposition"` +**GpuMfg**: `"none"`, `"nvidia"`, `"amd"` ## Authentication @@ -807,7 +816,13 @@ Body (all optional): "enabled": boolean, "load_cpu": number, "load_memory": number, - "load_disk": number + "load_disk": number, + "ssh_user": "string", + // SSH username for host utilities (default: root) + "ssh_key": "string" + | + null + // SSH private key (PEM format) - use null to clear } ``` @@ -846,8 +861,12 @@ Body: // Optional - default 1.0 "load_memory": number, // Optional - default 1.0 - "load_disk": number + "load_disk": number, // Optional - default 1.0 + "ssh_user": "string", + // Optional - SSH username for host utilities (default: root) + "ssh_key": "string" + // Optional - SSH private key (PEM format) } ``` @@ -1123,6 +1142,12 @@ Body: // optional "cpu": number, // CPU cores + "cpu_mfg": "string", + // optional - CpuMfg enum, default "unknown" (matches any host) + "cpu_arch": "string", + // optional - CpuArch enum, default "unknown" (matches any host) + "cpu_features": ["string"], + // optional - array of CpuFeature enum values, default [] (matches any host) "memory": number, // Memory in bytes "disk_size": number, @@ -1168,6 +1193,12 @@ Body (all optional): "enabled": boolean, "expires": "string (ISO 8601) | null", "cpu": number, + "cpu_mfg": "string", + // CpuMfg enum - filter hosts by CPU manufacturer + "cpu_arch": "string", + // CpuArch enum - filter hosts by CPU architecture + "cpu_features": ["string"], + // array of CpuFeature enum values - filter hosts by required CPU features "memory": number, "disk_size": number, "disk_type": "string", @@ -1344,6 +1375,12 @@ Body: "region_id": number, "currency": "string", // e.g., "USD", "EUR", "BTC" + "cpu_mfg": "string", + // optional - CpuMfg enum, default "unknown" (matches any host) + "cpu_arch": "string", + // optional - CpuArch enum, default "unknown" (matches any host) + "cpu_features": ["string"], + // optional - array of CpuFeature enum values, default [] (matches any host) "cpu_cost": number, // Cost per CPU core per month in smallest currency units (cents/millisats) "memory_cost": number, @@ -1395,6 +1432,12 @@ Body (all optional): "expires": "string (ISO 8601) | null", "region_id": number, "currency": "string", + "cpu_mfg": "string", + // CpuMfg enum - filter hosts by CPU manufacturer + "cpu_arch": "string", + // CpuArch enum - filter hosts by CPU architecture + "cpu_features": ["string"], + // array of CpuFeature enum values - filter hosts by required CPU features "cpu_cost": number, // Cost per CPU core in smallest currency units (cents/millisats) "memory_cost": number, @@ -2740,6 +2783,12 @@ The RBAC system uses the following permission format: `resource::action` }, "ip": "string", "cpu": number, + "cpu_mfg": "string", + // CpuMfg enum - detected CPU manufacturer + "cpu_arch": "string", + // CpuArch enum - detected CPU architecture + "cpu_features": ["string"], + // array of CpuFeature enum values - detected CPU features "memory": number, "enabled": boolean, "load_cpu": number, @@ -2773,7 +2822,11 @@ The RBAC system uses the following permission format: `resource::action` // Available memory in bytes "active_vms": number // Number of active VMs on this host - } + }, + "ssh_user": "string | null", + // SSH username for host utilities (null if not configured) + "ssh_key_configured": boolean + // Whether SSH key is configured (key itself is not exposed) } ``` diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 1f8d77dc..60d65696 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -7,6 +7,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- **2026-02-20** - Added CPU-aware host filtering to VM Templates, Custom Pricing, and Hosts (Admin API) + - New enums: `CpuMfg`, `CpuArch`, `CpuFeature`, `GpuMfg` + - `POST /api/admin/v1/vm_templates` — Added optional `cpu_mfg`, `cpu_arch`, `cpu_features` fields + - `PATCH /api/admin/v1/vm_templates/{id}` — Added optional `cpu_mfg`, `cpu_arch`, `cpu_features` fields + - `POST /api/admin/v1/custom_pricing` — Added optional `cpu_mfg`, `cpu_arch`, `cpu_features` fields + - `PATCH /api/admin/v1/custom_pricing/{id}` — Added optional `cpu_mfg`, `cpu_arch`, `cpu_features` fields + - `AdminHostInfo` response now includes `cpu_mfg`, `cpu_arch`, `cpu_features` (detected via lnvps-host-info) + - When `cpu_mfg`/`cpu_arch` is "unknown" or `cpu_features` is empty, no filtering is applied (matches any host) + +- **2026-02-20** - Added SSH credentials for host utilities to Admin Host API + - `POST /api/admin/v1/hosts` — Added optional `ssh_user` and `ssh_key` fields for host creation + - `PATCH /api/admin/v1/hosts/{id}` — Added optional `ssh_user` and `ssh_key` fields for host update + - `AdminHostInfo` response now includes `ssh_user` (string or null) and `ssh_key_configured` (boolean) + - SSH key itself is never exposed in responses for security (only a boolean indicator) + - SSH credentials are used by the PatchHosts worker to run `lnvps-host-info` utility for CPU/GPU detection + +- **2026-02-20** - Added CPU feature requirements to custom VM requests (User API) + - `POST /api/v1/vm/custom` — `cpu_mfg`, `cpu_arch`, `cpu_feature` fields now accept strings instead of enums + - Valid `cpu_mfg` values: "intel", "amd", "apple", "nvidia", "unknown" + - Valid `cpu_arch` values: "x86_64", "arm64", "unknown" + - CPU features are parsed from strings (e.g. "AVX2", "AES", "VMX"); invalid values are silently ignored + - **2026-02-17** - Added embedded API documentation served at root path (both User and Admin APIs) - `GET /` or `GET /index.html` - Renders API documentation with markdown viewer - `GET /docs/endpoints.md` - Raw markdown content of API endpoints documentation diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index e0d6bccb..d0ab8183 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -10,6 +10,23 @@ This document provides comprehensive API specifications for generating TypeScrip - **Error Response Format**: `{ "error": "Error message" }` - **Success Response Format**: `{ "data": }` +## Enums + +**DiskType**: `"hdd"`, `"ssd"` +**DiskInterface**: `"sata"`, `"scsi"`, `"pcie"` +**VmState**: `"running"`, `"stopped"`, `"pending"`, `"error"`, `"unknown"` +**CostPlanIntervalType**: `"day"`, `"month"`, `"year"` +**OsDistribution**: `"ubuntu"`, `"debian"`, `"centos"`, `"fedora"`, `"freebsd"`, `"opensuse"`, `"archlinux"`, +`"redhatenterprise"` +**CpuMfg**: `"unknown"`, `"intel"`, `"amd"`, `"apple"`, `"nvidia"`, `"arm"` +**CpuArch**: `"unknown"`, `"x86_64"`, `"arm64"` +**CpuFeature**: `"SSE"`, `"SSE2"`, `"SSE3"`, `"SSSE3"`, `"SSE4_1"`, `"SSE4_2"`, `"AVX"`, `"AVX2"`, `"FMA"`, `"F16C"`, +`"AVX512F"`, `"AVX512VNNI"`, `"AVX512BF16"`, `"AVXVNNI"`, `"NEON"`, `"SVE"`, `"SVE2"`, `"AES"`, `"SHA"`, `"SHA512"`, +`"PCLMULQDQ"`, `"RNG"`, `"GFNI"`, `"VAES"`, `"VPCLMULQDQ"`, `"VMX"`, `"NestedVirt"`, `"AMX"`, `"SME"`, `"SGX"`, `"SEV"`, +`"TDX"`, `"EncodeH264"`, `"EncodeHEVC"`, `"EncodeAV1"`, `"EncodeVP9"`, `"EncodeJPEG"`, `"DecodeH264"`, `"DecodeHEVC"`, +`"DecodeAV1"`, `"DecodeVP9"`, `"DecodeJPEG"`, `"DecodeMPEG2"`, `"DecodeVC1"`, `"VideoScaling"`, `"VideoDeinterlace"`, +`"VideoCSC"`, `"VideoComposition"` + ## Authentication Types ```typescript @@ -69,6 +86,9 @@ interface VmTemplate { created: string; // ISO 8601 datetime expires?: string; // ISO 8601 datetime cpu: number; // Number of CPU cores + cpu_mfg?: string; // CPU manufacturer (e.g. "intel", "amd"; omitted if unknown) + cpu_arch?: string; // CPU architecture (e.g. "x86_64", "arm64"; omitted if unknown) + cpu_features?: string[]; // Required CPU features (e.g. ["AVX2", "AES"]; omitted if empty) memory: number; // Memory in bytes disk_size: number; // Disk size in bytes disk_type: 'hdd' | 'ssd'; @@ -120,6 +140,9 @@ interface CustomTemplateParams { id: number; name: string; region: VmHostRegion; + cpu_mfg?: string; // CPU manufacturer (e.g. "intel", "amd"; omitted if unknown) + cpu_arch?: string; // CPU architecture (e.g. "x86_64", "arm64"; omitted if unknown) + cpu_features?: string[]; // Required CPU features (e.g. ["AVX2", "AES"]; omitted if empty) max_cpu: number; min_cpu: number; min_memory: number; // In bytes diff --git a/Cargo.lock b/Cargo.lock index 2021c83d..1b98a1d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,6 +394,26 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log 0.4.29", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", +] + [[package]] name = "bip39" version = "2.2.2" @@ -537,6 +557,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -608,6 +637,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.57" @@ -803,6 +843,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cros-libva" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5dfcdd9b868cc52eb2b5f5b8dea053314b205073ab9f13fa118c479d3a161" +dependencies = [ + "bindgen", + "bitflags", + "log 0.4.29", + "pkg-config", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1454,6 +1508,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2038,6 +2098,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2321,7 +2390,7 @@ dependencies = [ "idna", "mime", "native-tls", - "nom", + "nom 8.0.0", "percent-encoding", "quoted_printable", "rustls", @@ -2339,6 +2408,16 @@ version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2583,6 +2662,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "lnvps_host_util" +version = "0.1.0" +dependencies = [ + "cros-libva", + "nvml-wrapper", + "raw-cpuid", + "serde", + "serde_json", +] + [[package]] name = "lnvps_nostr" version = "0.1.0" @@ -2680,6 +2770,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -2747,6 +2843,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2911,6 +3017,29 @@ dependencies = [ "libm", ] +[[package]] +name = "nvml-wrapper" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5" +dependencies = [ + "bitflags", + "libloading", + "nvml-wrapper-sys", + "static_assertions", + "thiserror 1.0.69", + "wrapcenum-derive", +] + +[[package]] +name = "nvml-wrapper-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698d45156f28781a4e79652b6ebe2eaa0589057d588d3aec1333f6466f13fcb5" +dependencies = [ + "libloading", +] + [[package]] name = "nwc" version = "0.44.0" @@ -3419,7 +3548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log 0.4.29", "multimap", "petgraph", @@ -3440,7 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -3526,7 +3655,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.6.2", "thiserror 2.0.18", @@ -3547,7 +3676,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -3651,6 +3780,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redis" version = "1.0.3" @@ -3839,6 +3977,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4639,6 +4783,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -6008,6 +6158,18 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wrapcenum-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 2644b9b7..621b90e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "lnvps_nostr", "lnvps_operator", "lnvps_health", + "lnvps_host_util", "try-procedure", ] diff --git a/lnvps_api/Dockerfile b/lnvps_api/Dockerfile index cb198fba..f6f7977e 100644 --- a/lnvps_api/Dockerfile +++ b/lnvps_api/Dockerfile @@ -1,16 +1,23 @@ ARG IMAGE=rust:trixie +ARG HOST_INFO_IMAGE=lnvps-host-info FROM $IMAGE AS build WORKDIR /app/src COPY . . -RUN apt update && apt -y install protobuf-compiler libvirt-dev +RUN apt update && apt -y install protobuf-compiler libvirt-dev libva-dev RUN cargo test --locked --package lnvps_api -- --test-threads=1 \ && cargo install --locked --root /app/build --path lnvps_api lnvps_api +# Copy host-info binaries from pre-built multi-arch images +FROM ${HOST_INFO_IMAGE}:latest-amd64 AS host-info-amd64 +FROM ${HOST_INFO_IMAGE}:latest-arm64 AS host-info-arm64 + FROM debian:trixie-slim AS runner WORKDIR /app RUN apt update && \ apt install -y ca-certificates libssl3 && \ rm -rf /var/lib/apt/lists/* COPY --from=build /app/build . +COPY --from=host-info-amd64 /app/lnvps-host-info ./bin/lnvps-host-info +COPY --from=host-info-arm64 /app/lnvps-host-info ./bin/lnvps-host-info-arm64 ENTRYPOINT ["./bin/lnvps_api"] \ No newline at end of file diff --git a/lnvps_api/src/api/ip_space.rs b/lnvps_api/src/api/ip_space.rs index bcff56a3..ac3597e4 100644 --- a/lnvps_api/src/api/ip_space.rs +++ b/lnvps_api/src/api/ip_space.rs @@ -87,7 +87,6 @@ async fn v1_get_ip_space( // ============================================================================ // Helper function to find an available subnet -#[allow(dead_code)] pub(super) async fn find_available_subnet( db: &std::sync::Arc, parent_network: &ipnetwork::IpNetwork, @@ -206,7 +205,6 @@ pub(super) async fn find_available_subnet( )) } -#[allow(dead_code)] fn subnets_overlap(a: &ipnetwork::IpNetwork, b: &ipnetwork::IpNetwork) -> bool { a.contains(b.network()) || b.contains(a.network()) } diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 3ca1be04..8394d9d8 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -215,8 +215,7 @@ impl ApiInvoiceItem { .map_err(|_| anyhow::anyhow!("Invalid currency: {}", currency))?; let formatted_amount = payments_rs::currency::CurrencyAmount::from_u64(cur, amount).to_string(); - let formatted_tax = - payments_rs::currency::CurrencyAmount::from_u64(cur, tax).to_string(); + let formatted_tax = payments_rs::currency::CurrencyAmount::from_u64(cur, tax).to_string(); let duration = Duration::from_secs(time_seconds); let formatted_duration = format_duration(duration).to_string(); Ok(Self { @@ -450,10 +449,24 @@ pub struct ApiCustomVmRequest { pub disk: u64, pub disk_type: ApiDiskType, pub disk_interface: ApiDiskInterface, + /// CPU manufacturer as string (e.g. "intel", "amd", "apple") + pub cpu_mfg: Option, + /// CPU architecture as string (e.g. "x86_64", "arm64") + pub cpu_arch: Option, + /// CPU features as strings (e.g. "AVX2", "AES", "VMX") + #[serde(default)] + pub cpu_feature: Vec, } impl From for VmCustomTemplate { fn from(value: ApiCustomVmRequest) -> Self { + // Parse CPU features from strings + let cpu_features: Vec = value + .cpu_feature + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + VmCustomTemplate { id: 0, cpu: value.cpu, @@ -462,6 +475,9 @@ impl From for VmCustomTemplate { disk_type: value.disk_type.into(), disk_interface: value.disk_interface.into(), pricing_id: value.pricing_id, + cpu_mfg: value.cpu_mfg.and_then(|s| s.parse().ok()).unwrap_or_default(), + cpu_arch: value.cpu_arch.and_then(|s| s.parse().ok()).unwrap_or_default(), + cpu_features: cpu_features.into(), } } } @@ -737,7 +753,6 @@ impl From for ApiIpSpacePricing { } #[derive(Serialize)] -#[allow(dead_code)] pub struct ApiIpRangeSubscription { pub id: u64, pub cidr: String, @@ -748,7 +763,6 @@ pub struct ApiIpRangeSubscription { } impl ApiIpRangeSubscription { - #[allow(dead_code)] pub async fn from_subscription_with_space( db: &dyn lnvps_db::LNVpsDbBase, sub: lnvps_db::IpRangeSubscription, @@ -787,14 +801,12 @@ pub enum ApiCreateSubscriptionLineItemRequest { #[serde(rename = "asn_sponsoring")] AsnSponsoring { - #[allow(dead_code)] asn: u32, // Add pricing/plan details here }, #[serde(rename = "dns_hosting")] DnsHosting { - #[allow(dead_code)] domain: String, // Add pricing/plan details here }, @@ -806,7 +818,13 @@ mod tests { use chrono::Utc; use lnvps_db::{EncryptedString, PaymentMethod, PaymentType, VmPayment}; - fn make_payment(currency: &str, amount: u64, tax: u64, processing_fee: u64, time_value: u64) -> VmPayment { + fn make_payment( + currency: &str, + amount: u64, + tax: u64, + processing_fee: u64, + time_value: u64, + ) -> VmPayment { VmPayment { id: vec![0u8; 32], vm_id: 1, diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index bf650dfa..cbeb75a7 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet}; use std::io::Cursor; use std::str::FromStr; -use lnvps_api_common::retry::{Pipeline, RetryPolicy}; +use lnvps_api_common::retry::{OpError, Pipeline, RetryPolicy}; use lnvps_api_common::{ApiCurrency, ApiData, ApiError, ApiPrice, ApiResult, ApiUserSshKey, ApiVmOsImage, ApiVmTemplate, EuVatClient, Nip98Auth, PageQuery, UpgradeConfig, WorkJob}; use lnvps_db::{ @@ -103,10 +103,10 @@ async fn v1_patch_account( // validate nwc string #[cfg(feature = "nostr-nwc")] if let Some(Some(nwc)) = &req.nwc_connection_string { - match nwc::prelude::NostrWalletConnectUri::parse(nwc) { + match nwc::prelude::NostrWalletConnectURI::parse(nwc) { Ok(s) => { // test connection - let client = nwc::NostrWalletConnect::new(s); + let client = nwc::NWC::new(s); let info = client .get_info() .await @@ -750,7 +750,7 @@ async fn v1_terminal_proxy( let client = get_host_client(&host, &this.settings.provisioner) .map_err(|_| "Failed to get host client")?; - let mut terminal = client.connect_terminal(&vm, &host).await.map_err(|e| { + let mut terminal = client.connect_terminal(&vm).await.map_err(|e| { error!("Failed to start terminal proxy: {}", e); "Failed to open terminal proxy" })?; diff --git a/lnvps_api/src/api/subscriptions.rs b/lnvps_api/src/api/subscriptions.rs index e7679a58..72b1e292 100644 --- a/lnvps_api/src/api/subscriptions.rs +++ b/lnvps_api/src/api/subscriptions.rs @@ -144,7 +144,7 @@ async fn v1_create_subscription( // Calculate total setup fee and recurring amount let mut total_setup_fee = 0u64; - let mut _total_recurring_amount = 0u64; + let mut total_recurring_amount = 0u64; let mut line_items_to_create = Vec::new(); let mut derived_company_id: Option = None; @@ -179,7 +179,7 @@ async fn v1_create_subscription( } total_setup_fee += pricing.setup_fee as u64; - _total_recurring_amount += pricing.price_per_month as u64; + total_recurring_amount += pricing.price_per_month as u64; line_items_to_create.push(( format!("IP Range: /{} from {}", pricing.prefix_size, ip_space.cidr), diff --git a/lnvps_api/src/data_migration/mod.rs b/lnvps_api/src/data_migration/mod.rs index ef2f9e42..7f6d24e0 100644 --- a/lnvps_api/src/data_migration/mod.rs +++ b/lnvps_api/src/data_migration/mod.rs @@ -3,6 +3,7 @@ use crate::data_migration::dns::DnsDataMigration; use crate::data_migration::encryption_migration::EncryptionDataMigration; use crate::data_migration::ip6_init::Ip6InitDataMigration; use crate::data_migration::payment_method_config::PaymentMethodConfigMigration; +use crate::data_migration::ssh_key_migration::SshKeyMigration; use crate::provisioner::LNVpsProvisioner; use crate::settings::Settings; use anyhow::Result; @@ -17,6 +18,7 @@ mod dns; mod encryption_migration; mod ip6_init; mod payment_method_config; +mod ssh_key_migration; /// Basic data migration to run at startup pub trait DataMigration: Send + Sync { @@ -50,6 +52,9 @@ pub async fn run_data_migrations( settings.clone(), ))); + // Migrate SSH key from proxmox config to database + migrations.push(Box::new(SshKeyMigration::new(db.clone(), settings.clone()))); + info!("Running {} data migrations", migrations.len()); for migration in migrations { if let Err(e) = migration.migrate().await { diff --git a/lnvps_api/src/data_migration/ssh_key_migration.rs b/lnvps_api/src/data_migration/ssh_key_migration.rs new file mode 100644 index 00000000..5f90ac7e --- /dev/null +++ b/lnvps_api/src/data_migration/ssh_key_migration.rs @@ -0,0 +1,81 @@ +use crate::data_migration::DataMigration; +use crate::settings::Settings; +use anyhow::{Context, Result}; +use lnvps_db::LNVpsDb; +use log::info; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +/// Migrates SSH key from proxmox config file to the database +pub struct SshKeyMigration { + db: Arc, + settings: Settings, +} + +impl SshKeyMigration { + pub fn new(db: Arc, settings: Settings) -> Self { + Self { db, settings } + } +} + +impl DataMigration for SshKeyMigration { + fn migrate(&self) -> Pin> + Send>> { + let db = self.db.clone(); + let settings = self.settings.clone(); + Box::pin(async move { + // Get SSH config from proxmox settings + let ssh_config = match &settings.provisioner.proxmox { + Some(proxmox) => match &proxmox.ssh { + Some(ssh) => ssh.clone(), + None => { + info!("No SSH config in proxmox settings, skipping SSH key migration"); + return Ok(()); + } + }, + None => { + info!("No proxmox config found, skipping SSH key migration"); + return Ok(()); + } + }; + + // Read the SSH key file + let key_content = std::fs::read_to_string(&ssh_config.key) + .with_context(|| format!("Failed to read SSH key file: {:?}", ssh_config.key))?; + + info!( + "Starting SSH key migration from config file: {:?}", + ssh_config.key + ); + + // Get all hosts + let hosts = db.list_hosts().await?; + let mut migrated_count = 0; + + for mut host in hosts { + // Skip hosts that already have SSH key configured + if host.ssh_key.is_some() { + continue; + } + + // Update host with SSH credentials + host.ssh_user = Some(ssh_config.user.clone()); + host.ssh_key = Some(key_content.clone().into()); + db.update_host(&host).await?; + + info!( + "Migrated SSH key to host '{}' (id={})", + host.name, host.id + ); + migrated_count += 1; + } + + info!( + "SSH key migration completed: {} hosts updated", + migrated_count + ); + + Ok(()) + }) + } +} diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs index 4f80a7fe..83e54b62 100644 --- a/lnvps_api/src/dvm/lnvps.rs +++ b/lnvps_api/src/dvm/lnvps.rs @@ -87,6 +87,9 @@ impl DVMHandler for LnvpsDvm { disk_type: DiskType::from_str(disk_type)?, disk_interface: DiskInterface::from_str(disk_interface)?, pricing_id: pricing.id, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), }; let uid = db.upsert_user(request.event.pubkey.as_bytes()).await?; @@ -179,10 +182,13 @@ mod tests { expires: None, region_id: 1, currency: "EUR".to_string(), - cpu_cost: 150, // €1.50 in cents per CPU - memory_cost: 50, // €0.50 in cents per GB - ip4_cost: 150, // €1.50 in cents per IPv4 - ip6_cost: 5, // €0.05 in cents per IPv6 + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), + cpu_cost: 150, // €1.50 in cents per CPU + memory_cost: 50, // €0.50 in cents per GB + ip4_cost: 150, // €1.50 in cents per IPv4 + ip6_cost: 5, // €0.05 in cents per IPv6 min_cpu: 0, max_cpu: 0, min_memory: 0, @@ -197,7 +203,7 @@ mod tests { pricing_id: 1, kind: DiskType::SSD, interface: DiskInterface::PCIe, - cost: 5, // €0.05 in cents per GB + cost: 5, // €0.05 in cents per GB min_disk_size: 5 * crate::GB, max_disk_size: 1 * crate::TB, }, diff --git a/lnvps_api/src/host/libvirt.rs b/lnvps_api/src/host/libvirt.rs index 4d4b4cbc..83895ae7 100644 --- a/lnvps_api/src/host/libvirt.rs +++ b/lnvps_api/src/host/libvirt.rs @@ -9,7 +9,7 @@ use chrono::Utc; use lnvps_api_common::VmRunningStates; use lnvps_api_common::retry::{OpError, OpResult}; use lnvps_api_common::{VmRunningState, op_fatal}; -use lnvps_db::{LNVpsDb, Vm, VmHost, VmOsImage}; +use lnvps_db::{LNVpsDb, Vm, VmOsImage}; use log::info; use rand::random; use serde::{Deserialize, Serialize}; @@ -241,7 +241,7 @@ impl VmHostClient for LibVirtHost { todo!() } - async fn connect_terminal(&self, vm: &Vm, _host: &VmHost) -> OpResult { + async fn connect_terminal(&self, vm: &Vm) -> OpResult { todo!() } } diff --git a/lnvps_api/src/host/mod.rs b/lnvps_api/src/host/mod.rs index 8195af6f..8aa1d709 100644 --- a/lnvps_api/src/host/mod.rs +++ b/lnvps_api/src/host/mod.rs @@ -78,7 +78,7 @@ pub trait VmHostClient: Send + Sync { ) -> OpResult>; /// Connect to terminal serial port - async fn connect_terminal(&self, vm: &Vm, host: &VmHost) -> OpResult; + async fn connect_terminal(&self, vm: &Vm) -> OpResult; } pub async fn get_vm_host_client( @@ -282,6 +282,9 @@ mod tests { created: Default::default(), expires: None, cpu: 2, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), memory: 2 * GB, disk_size: 100 * GB, disk_type: DiskType::SSD, @@ -313,6 +316,9 @@ mod tests { name: "mock".to_string(), ip: "https://localhost:8006".to_string(), cpu: 20, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), memory: 128 * GB, enabled: true, api_token: "mock".into(), @@ -320,6 +326,8 @@ mod tests { load_memory: 1.0, load_disk: 1.0, vlan_id: Some(100), + ssh_user: None, + ssh_key: None, }, disk: VmHostDisk { id: 1, diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index 56b968b0..06d951d5 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use crate::host::{ FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostDiskInfo, VmHostInfo, @@ -13,7 +11,7 @@ use chrono::Utc; use ipnetwork::IpNetwork; use lnvps_api_common::retry::{OpError, OpResult, Pipeline, RetryPolicy}; use lnvps_api_common::{VmRunningState, VmRunningStates, op_fatal, parse_gateway}; -use lnvps_db::{DiskType, IpRangeAllocationMode, Vm, VmHost, VmOsImage}; +use lnvps_db::{DiskType, IpRangeAllocationMode, Vm, VmOsImage}; use log::{info, warn}; use rand::random; use reqwest::{Method, Url}; @@ -1238,7 +1236,7 @@ impl VmHostClient for ProxmoxClient { Ok(r.into_iter().map(TimeSeriesData::from).collect()) } - async fn connect_terminal(&self, vm: &Vm, host: &VmHost) -> OpResult { + async fn connect_terminal(&self, vm: &Vm) -> OpResult { let ssh = self .ssh .as_ref() @@ -1248,14 +1246,7 @@ impl VmHostClient for ProxmoxClient { let vm_id: ProxmoxVmId = vm.id.into(); let socket_path = format!("/var/run/qemu-server/{}.serial0", vm_id); - let host_url: Url = host - .ip - .parse() - .map_err(|e| OpError::Fatal(anyhow::anyhow!("Invalid VM host IP URL: {}", e)))?; - let host = host_url - .host() - .ok_or_else(|| OpError::Fatal(anyhow::anyhow!("VM host IP URL has no host"))) - .map(|h| h.to_string())?; + let host = self.api.base().host().unwrap().to_string(); let ssh_user = ssh.user.clone(); let ssh_key = ssh.key.clone(); diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index 2f41d1bf..14f52b2c 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -391,7 +391,7 @@ impl VmHostClient for MockVmHost { Ok(vec![]) } - async fn connect_terminal(&self, vm: &Vm, _host: &VmHost) -> OpResult { + async fn connect_terminal(&self, vm: &Vm) -> OpResult { use tokio::sync::mpsc::channel; let (client_tx, client_rx) = channel::>(256); let (server_tx, mut server_rx) = channel::>(256); diff --git a/lnvps_api/src/provisioner/integration_retry_tests.rs b/lnvps_api/src/provisioner/integration_retry_tests.rs index 6455c49f..d5b0b57c 100644 --- a/lnvps_api/src/provisioner/integration_retry_tests.rs +++ b/lnvps_api/src/provisioner/integration_retry_tests.rs @@ -10,7 +10,7 @@ mod tests { use async_trait::async_trait; use lnvps_api_common::retry::{OpError, OpResult}; use lnvps_api_common::{MockDb, VmRunningState}; - use lnvps_db::{LNVpsDbBase, User, UserSshKey, Vm, VmHost}; + use lnvps_db::{LNVpsDbBase, User, UserSshKey, Vm}; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; @@ -159,8 +159,8 @@ mod tests { self.inner.get_time_series_data(vm, series).await } - async fn connect_terminal(&self, vm: &Vm, host: &VmHost) -> OpResult { - self.inner.connect_terminal(vm, host).await + async fn connect_terminal(&self, vm: &Vm) -> OpResult { + self.inner.connect_terminal(vm).await } } diff --git a/lnvps_api/src/provisioner/lnvps.rs b/lnvps_api/src/provisioner/lnvps.rs index 0a94a89b..9fd0bacc 100644 --- a/lnvps_api/src/provisioner/lnvps.rs +++ b/lnvps_api/src/provisioner/lnvps.rs @@ -230,11 +230,11 @@ impl LNVpsProvisioner { ); // Parse NWC connection string - let nwc_uri = nwc::prelude::NostrWalletConnectUri::from_str(nwc_connection_string) + let nwc_uri = nwc::prelude::NostrWalletConnectURI::from_str(nwc_connection_string) .context("Invalid NWC connection string")?; // Create nostr client for NWC - let client = nwc::NostrWalletConnect::new(nwc_uri); + let client = nwc::NWC::new(nwc_uri); client.pay_invoice(PayInvoiceRequest::new(invoice)).await?; info!("Successful NWC auto-renewal payment for VM {}", vm_id); Ok(vm_payment) @@ -325,7 +325,7 @@ impl LNVpsProvisioner { // Parse subscription currency let subscription_currency = Currency::from_str(&subscription.currency) - .map_err(|_e| anyhow::anyhow!("Invalid currency"))?; + .map_err(|e| anyhow::anyhow!("Invalid currency"))?; let list_price = CurrencyAmount::from_u64(subscription_currency, list_price_amount); // Create pricing engine for currency conversion @@ -343,9 +343,16 @@ impl LNVpsProvisioner { let tax = pe .get_tax_for_user(user.id, converted.amount.value()) .await?; - + // Calculate processing fee using subscription's company_id - let processing_fee = pe.calculate_processing_fee(subscription.company_id, method, converted.amount.currency(), converted.amount.value()).await; + let processing_fee = pe + .calculate_processing_fee( + subscription.company_id, + method, + converted.amount.currency(), + converted.amount.value(), + ) + .await; // Generate payment based on method let subscription_payment = match method { @@ -532,7 +539,10 @@ impl LNVpsProvisioner { let order = rev .create_order( &desc, - CurrencyAmount::from_u64(p.currency, p.amount + p.tax + p.processing_fee), + CurrencyAmount::from_u64( + p.currency, + p.amount + p.tax + p.processing_fee, + ), None, ) .await?; @@ -809,7 +819,7 @@ impl LNVpsProvisioner { rate: cost_difference.upgrade.rate, time_value: 0, //upgrades dont add time new_expiry: Default::default(), - tax: 0, // No tax on upgrades for now + tax: 0, // No tax on upgrades for now processing_fee: 0, // No processing fee on upgrades for now }; let upgrade_params_json = serde_json::to_string(cfg)?; @@ -879,6 +889,9 @@ impl LNVpsProvisioner { disk_type: current_template.disk_type, disk_interface: current_template.disk_interface, pricing_id: custom_pricing.id, + cpu_mfg: current_template.cpu_mfg.clone(), + cpu_arch: current_template.cpu_arch.clone(), + cpu_features: current_template.cpu_features.clone(), }; // Validate the upgrade (ensure we're not downgrading) @@ -1053,10 +1066,10 @@ mod tests { use super::*; use crate::mocks::{MockDnsServer, MockNode, MockRouter}; use crate::settings::mock_settings; - use lnvps_api_common::{InMemoryRateCache, MockDb, MockExchangeRate, Ticker}; + use lnvps_api_common::{GB, InMemoryRateCache, MockDb, MockExchangeRate, TB, Ticker}; use lnvps_db::{ AccessPolicy, DiskInterface, DiskType, LNVpsDbBase, NetworkAccessPolicy, RouterKind, User, - UserSshKey, VmTemplate, + UserSshKey, VmCustomPricing, VmCustomPricingDisk, VmTemplate, }; use std::net::IpAddr; use std::str::FromStr; @@ -1272,8 +1285,11 @@ mod tests { created: Default::default(), expires: None, cpu: 64, - memory: 512 * lnvps_api_common::GB, - disk_size: 20 * lnvps_api_common::TB, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), + memory: 512 * GB, + disk_size: 20 * TB, disk_type: DiskType::SSD, disk_interface: DiskInterface::PCIe, cost_plan_id: 1, @@ -1291,4 +1307,371 @@ mod tests { } Ok(()) } + + // ── helpers ────────────────────────────────────────────────────────────── + + /// Build a minimal provisioner backed by the given MockDb (no DNS, no rates needed). + fn make_provisioner(db: Arc) -> LNVpsProvisioner { + let node = Arc::new(MockNode::default()); + let rates = Arc::new(MockExchangeRate::new()); + LNVpsProvisioner::new(mock_settings(), db, node, rates, None) + } + + /// Insert a VmCustomPricing + one VmCustomPricingDisk into db and return the pricing id. + async fn insert_custom_pricing( + db: &MockDb, + disk_type: DiskType, + disk_interface: DiskInterface, + ) -> Result { + let mut pricing_map = db.custom_pricing.lock().await; + let pricing_id = pricing_map.keys().max().unwrap_or(&0) + 1; + pricing_map.insert( + pricing_id, + VmCustomPricing { + id: pricing_id, + name: "test-pricing".to_string(), + enabled: true, + region_id: 1, + currency: "EUR".to_string(), + cpu_cost: 100, + memory_cost: 50, + ip4_cost: 150, + ip6_cost: 5, + min_cpu: 1, + max_cpu: 32, + min_memory: GB, + max_memory: 128 * GB, + ..Default::default() + }, + ); + drop(pricing_map); + + let mut disk_map = db.custom_pricing_disk.lock().await; + let disk_id = disk_map.keys().max().unwrap_or(&0) + 1; + disk_map.insert( + disk_id, + VmCustomPricingDisk { + id: disk_id, + pricing_id, + kind: disk_type, + interface: disk_interface, + cost: 5, + min_disk_size: GB, + max_disk_size: 10 * TB, + }, + ); + Ok(pricing_id) + } + + /// Insert a VM that uses the default mock standard template (template_id = 1). + async fn insert_standard_template_vm(db: &Arc) -> Result { + let (user, ssh_key) = add_user(db).await?; + let vm_id = db + .insert_vm(&Vm { + id: 0, + host_id: 1, + user_id: user.id, + image_id: 1, + template_id: Some(1), + custom_template_id: None, + ssh_key_id: ssh_key.id, + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + deleted: false, + ..Default::default() + }) + .await?; + Ok(vm_id) + } + + // ── create_upgrade_template tests ──────────────────────────────────────── + + /// CPU-only upgrade: new_cpu is applied; memory and disk come from the template. + #[tokio::test] + async fn test_create_upgrade_template_cpu_upgrade() -> Result<()> { + let db = Arc::new(MockDb::default()); + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + let cfg = UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let (_, template, new_tpl) = prov.create_upgrade_template(vm_id, &cfg).await?; + + assert_eq!(new_tpl.cpu, 4, "cpu should be upgraded value"); + assert_eq!( + new_tpl.memory, template.memory, + "memory should be unchanged" + ); + assert_eq!( + new_tpl.disk_size, template.disk_size, + "disk should be unchanged" + ); + assert_eq!(new_tpl.disk_type, template.disk_type); + assert_eq!(new_tpl.disk_interface, template.disk_interface); + assert_eq!(new_tpl.pricing_id, pricing_id); + Ok(()) + } + + /// Memory-only upgrade: new_memory is applied; cpu and disk come from the template. + #[tokio::test] + async fn test_create_upgrade_template_memory_upgrade() -> Result<()> { + let db = Arc::new(MockDb::default()); + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + let cfg = UpgradeConfig { + new_cpu: None, + new_memory: Some(8 * GB), + new_disk: None, + }; + let (_, template, new_tpl) = prov.create_upgrade_template(vm_id, &cfg).await?; + + assert_eq!(new_tpl.cpu, template.cpu, "cpu should be unchanged"); + assert_eq!(new_tpl.memory, 8 * GB, "memory should be upgraded value"); + assert_eq!( + new_tpl.disk_size, template.disk_size, + "disk should be unchanged" + ); + assert_eq!(new_tpl.pricing_id, pricing_id); + Ok(()) + } + + /// Disk-only upgrade: new_disk is applied; cpu and memory come from the template. + #[tokio::test] + async fn test_create_upgrade_template_disk_upgrade() -> Result<()> { + let db = Arc::new(MockDb::default()); + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + let cfg = UpgradeConfig { + new_cpu: None, + new_memory: None, + new_disk: Some(128 * GB), + }; + let (_, template, new_tpl) = prov.create_upgrade_template(vm_id, &cfg).await?; + + assert_eq!(new_tpl.cpu, template.cpu, "cpu should be unchanged"); + assert_eq!( + new_tpl.memory, template.memory, + "memory should be unchanged" + ); + assert_eq!(new_tpl.disk_size, 128 * GB, "disk should be upgraded value"); + assert_eq!(new_tpl.pricing_id, pricing_id); + Ok(()) + } + + /// All fields upgraded together. + #[tokio::test] + async fn test_create_upgrade_template_all_fields() -> Result<()> { + let db = Arc::new(MockDb::default()); + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + let cfg = UpgradeConfig { + new_cpu: Some(8), + new_memory: Some(16 * GB), + new_disk: Some(256 * GB), + }; + let (_, _, new_tpl) = prov.create_upgrade_template(vm_id, &cfg).await?; + + assert_eq!(new_tpl.cpu, 8); + assert_eq!(new_tpl.memory, 16 * GB); + assert_eq!(new_tpl.disk_size, 256 * GB); + assert_eq!(new_tpl.pricing_id, pricing_id); + Ok(()) + } + + /// All UpgradeConfig fields None: every prop must be copied from the current template. + #[tokio::test] + async fn test_create_upgrade_template_no_changes() -> Result<()> { + let db = Arc::new(MockDb::default()); + insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + let cfg = UpgradeConfig { + new_cpu: None, + new_memory: None, + new_disk: None, + }; + let (_, template, new_tpl) = prov.create_upgrade_template(vm_id, &cfg).await?; + + assert_eq!(new_tpl.cpu, template.cpu); + assert_eq!(new_tpl.memory, template.memory); + assert_eq!(new_tpl.disk_size, template.disk_size); + assert_eq!(new_tpl.disk_type, template.disk_type); + assert_eq!(new_tpl.disk_interface, template.disk_interface); + Ok(()) + } + + /// Error: VM has a custom template instead of a standard template. + #[tokio::test] + async fn test_create_upgrade_template_custom_template_vm_errors() -> Result<()> { + let db = Arc::new(MockDb::default()); + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + + let (user, ssh_key) = add_user(&db).await?; + let custom_tpl_id = db + .insert_custom_vm_template(&VmCustomTemplate { + id: 0, + cpu: 2, + memory: 2 * GB, + disk_size: 64 * GB, + disk_type: DiskType::SSD, + disk_interface: DiskInterface::PCIe, + pricing_id, + ..Default::default() + }) + .await?; + let vm_id = db + .insert_vm(&Vm { + id: 0, + host_id: 1, + user_id: user.id, + image_id: 1, + template_id: None, + custom_template_id: Some(custom_tpl_id), + ssh_key_id: ssh_key.id, + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:f0".to_string(), + deleted: false, + ..Default::default() + }) + .await?; + + let prov = make_provisioner(db); + let cfg = UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = prov.create_upgrade_template(vm_id, &cfg).await; + assert!(result.is_err(), "should fail for custom-template VMs"); + assert!( + result + .unwrap_err() + .to_string() + .contains("standard template"), + "error should mention standard template" + ); + Ok(()) + } + + /// Error: attempting to downgrade CPU. + #[tokio::test] + async fn test_create_upgrade_template_cpu_downgrade_errors() -> Result<()> { + let db = Arc::new(MockDb::default()); + insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + // Mock template has cpu = 2; requesting 1 is a downgrade. + let cfg = UpgradeConfig { + new_cpu: Some(1), + new_memory: None, + new_disk: None, + }; + let result = prov.create_upgrade_template(vm_id, &cfg).await; + assert!(result.is_err(), "should fail for CPU downgrade"); + assert!( + result + .unwrap_err() + .to_string() + .to_lowercase() + .contains("downgrade"), + "error should mention downgrade" + ); + Ok(()) + } + + /// Error: attempting to downgrade memory. + #[tokio::test] + async fn test_create_upgrade_template_memory_downgrade_errors() -> Result<()> { + let db = Arc::new(MockDb::default()); + insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + // Mock template has memory = 2 GB; requesting 1 GB is a downgrade. + let cfg = UpgradeConfig { + new_cpu: None, + new_memory: Some(GB), + new_disk: None, + }; + let result = prov.create_upgrade_template(vm_id, &cfg).await; + assert!(result.is_err(), "should fail for memory downgrade"); + assert!( + result + .unwrap_err() + .to_string() + .to_lowercase() + .contains("downgrade"), + "error should mention downgrade" + ); + Ok(()) + } + + /// Error: attempting to downgrade disk. + #[tokio::test] + async fn test_create_upgrade_template_disk_downgrade_errors() -> Result<()> { + let db = Arc::new(MockDb::default()); + insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + // Mock template has disk_size = 64 GB; requesting 32 GB is a downgrade. + let cfg = UpgradeConfig { + new_cpu: None, + new_memory: None, + new_disk: Some(32 * GB), + }; + let result = prov.create_upgrade_template(vm_id, &cfg).await; + assert!(result.is_err(), "should fail for disk downgrade"); + assert!( + result + .unwrap_err() + .to_string() + .to_lowercase() + .contains("downgrade"), + "error should mention downgrade" + ); + Ok(()) + } + + /// Error: no custom pricing available that matches the template's disk type/interface. + #[tokio::test] + async fn test_create_upgrade_template_no_compatible_pricing_errors() -> Result<()> { + let db = Arc::new(MockDb::default()); + // Insert pricing for HDD/SATA only — incompatible with the mock template (SSD/PCIe). + insert_custom_pricing(&*db, DiskType::HDD, DiskInterface::SATA).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db); + + let cfg = UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = prov.create_upgrade_template(vm_id, &cfg).await; + assert!( + result.is_err(), + "should fail when no compatible pricing exists" + ); + assert!( + result + .unwrap_err() + .to_string() + .to_lowercase() + .contains("no custom pricing"), + "error should mention missing custom pricing" + ); + Ok(()) + } } diff --git a/lnvps_api/src/router/mod.rs b/lnvps_api/src/router/mod.rs index 23e63e00..b67fdd7c 100644 --- a/lnvps_api/src/router/mod.rs +++ b/lnvps_api/src/router/mod.rs @@ -73,6 +73,7 @@ pub async fn get_router(db: &Arc, router_id: u64) -> OpResult Result<()> { + let tcp = TcpStream::connect(host).await?; + self.session.set_tcp_stream(tcp); + self.session.handshake()?; + self.session + .userauth_pubkey_memory(username, None, private_key_pem, None)?; + Ok(()) + } + pub async fn open_channel(&mut self) -> Result { let channel = self.session.channel_session()?; Ok(channel) @@ -57,4 +72,28 @@ impl SshClient { channel.wait_close()?; Ok((channel.exit_status()?, s)) } + + /// Upload a file to the remote host via SFTP + pub fn scp_upload(&self, local_data: &[u8], remote_path: &Path, mode: i32) -> Result<()> { + use std::io::Write; + + info!( + "SFTP upload to {:?} ({} bytes, mode {:o})", + remote_path, + local_data.len(), + mode + ); + + let sftp = self.session.sftp()?; + let mut file = sftp.create(remote_path)?; + file.write_all(local_data)?; + + // Set file permissions + let mut stat = sftp.stat(remote_path)?; + stat.perm = Some(mode as u32); + sftp.setstat(remote_path, stat)?; + + info!("SFTP upload complete"); + Ok(()) + } } diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 6be93158..d040266b 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -1,6 +1,7 @@ use crate::host::{FullVmInfo, get_host_client}; use crate::provisioner::LNVpsProvisioner; use crate::settings::{ProvisionerConfig, Settings, SmtpConfig}; +use crate::ssh_client::SshClient; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Datelike, Days, Utc}; use hickory_resolver::TokioResolver; @@ -15,15 +16,76 @@ use lnvps_api_common::{ WorkFeedback, WorkJob, WorkJobMessage, op_fatal, retry::{OpError, Pipeline, RetryPolicy}, }; -use lnvps_db::{LNVpsDb, Vm, VmHost, VmIpAssignment}; +use lnvps_db::{CpuArch, CpuFeature, CpuMfg, LNVpsDb, Vm, VmHost, VmIpAssignment}; use log::{debug, error, info, warn}; use nostr_sdk::{Client, EventBuilder, PublicKey, ToBech32}; +use serde::Deserialize; use std::collections::HashMap; use std::ops::{Add, Sub}; +use std::path::Path; use std::sync::Arc; use std::time::Duration; use tokio::task::JoinHandle; +/// Name of the host-info binary for x86_64 (expected in same directory as current executable) +const HOST_INFO_BINARY_NAME_X86_64: &str = "lnvps-host-info"; +/// Name of the host-info binary for arm64 (expected in same directory as current executable) +const HOST_INFO_BINARY_NAME_ARM64: &str = "lnvps-host-info-arm64"; +/// Remote path where the binary will be uploaded and executed on hosts +const HOST_INFO_REMOTE_PATH: &str = "/tmp/lnvps-host-info"; + +/// Get the path to the host-info binary for x86_64 (in same directory as current executable) +fn get_host_info_path() -> Option { + let current_exe = std::env::current_exe().ok()?; + let exe_dir = current_exe.parent()?; + Some(exe_dir.join(HOST_INFO_BINARY_NAME_X86_64)) +} + +/// Get the path to the host-info binary for the specified architecture +fn get_host_info_path_for_arch(arch: CpuArch) -> Option { + let current_exe = std::env::current_exe().ok()?; + let exe_dir = current_exe.parent()?; + let binary_name = match arch { + CpuArch::ARM64 => HOST_INFO_BINARY_NAME_ARM64, + _ => HOST_INFO_BINARY_NAME_X86_64, // Default to x86_64 + }; + Some(exe_dir.join(binary_name)) +} + +/// Extract hostname/IP from a URL or return the input if it's already a plain host +/// e.g. "https://192.168.1.1:8006/" -> "192.168.1.1" +/// "192.168.1.1" -> "192.168.1.1" +fn extract_host_from_url(input: &str) -> String { + // Strip protocol prefix if present + let without_protocol = input + .strip_prefix("https://") + .or_else(|| input.strip_prefix("http://")) + .unwrap_or(input); + + // Take everything before the first ':' or '/' (to strip port and path) + without_protocol + .split(|c| c == ':' || c == '/') + .next() + .unwrap_or(input) + .to_string() +} + +/// Host info output from lnvps-host-info utility +#[derive(Debug, Deserialize)] +struct HostInfoOutput { + cpu_mfg: String, + cpu_arch: String, + cpu_features: Vec, + #[allow(dead_code)] + cpu_model: Option, + #[allow(dead_code)] + gpu_mfg: String, + #[allow(dead_code)] + gpu_model: Option, + #[allow(dead_code)] + gpu_features: Vec, +} + /// Primary background worker logic /// Handles deleting expired VMs and sending notifications #[derive(Clone)] @@ -641,6 +703,21 @@ impl Worker { } } + // Run host-info utility to detect CPU/GPU features (only if binary exists) + match get_host_info_path() { + Some(p) if p.exists() => { + if let Err(e) = self.run_host_info(host).await { + warn!("Failed to run host-info on {}: {:?}", host.name, e); + } + } + _ => { + warn!( + "Host-info detection disabled: binary not found (expected at {:?})", + get_host_info_path() + ); + } + } + // Patch firewall configuration for all VMs on this host let vms = self.db.list_vms_on_host(host.id).await?; for vm in &vms { @@ -662,6 +739,130 @@ impl Worker { Ok(()) } + /// Install and run lnvps-host-info on a host to detect CPU/GPU features + async fn run_host_info(&self, host: &mut VmHost) -> Result<()> { + // Check if SSH credentials are configured + let ssh_key = match &host.ssh_key { + Some(key) => key.as_str().to_string(), + None => { + warn!("No SSH key configured for host {}, skipping host-info", host.name); + return Ok(()); + } + }; + let ssh_user = host.ssh_user.as_deref().unwrap_or("root"); + + // Extract hostname/IP from the host.ip field (may be a URL like https://1.2.3.4:8006/) + let ssh_host = extract_host_from_url(&host.ip); + + // Connect to host via SSH + let mut ssh = SshClient::new()?; + ssh.connect_with_key( + (ssh_host.as_str(), 22), + ssh_user, + &ssh_key, + ) + .await + .with_context(|| format!("Failed to SSH connect to host {} ({}@{}:22)", host.name, ssh_user, ssh_host))?; + + // Detect the host's architecture via uname -m + let (exit_code, arch_output) = ssh + .execute("uname -m") + .await + .with_context(|| format!("Failed to detect architecture on host {}", host.name))?; + + if exit_code != 0 { + bail!("uname -m failed with exit code {} on {}", exit_code, host.name); + } + + let remote_arch = match arch_output.trim() { + "x86_64" | "amd64" => CpuArch::X86_64, + "aarch64" | "arm64" => CpuArch::ARM64, + other => { + warn!("Unknown architecture '{}' on host {}, skipping host-info", other, host.name); + return Ok(()); + } + }; + + // Select the correct binary based on the detected architecture + let binary_path = get_host_info_path_for_arch(remote_arch) + .with_context(|| "Failed to get host-info binary path")?; + + // Check if the binary exists for this architecture + if !binary_path.exists() { + warn!( + "Host-info binary for {:?} not found at {:?}, skipping host {}", + remote_arch, binary_path, host.name + ); + return Ok(()); + } + + // Read the local binary + let binary_data = std::fs::read(&binary_path) + .with_context(|| format!("Failed to read host-info binary from {:?}", binary_path))?; + + // Upload the binary + ssh.scp_upload( + &binary_data, + Path::new(HOST_INFO_REMOTE_PATH), + 0o755, + ) + .with_context(|| format!("Failed to upload host-info to {}", host.name))?; + + info!("Uploaded host-info to {}", host.name); + + // Execute the binary and capture output + let (exit_code, output) = ssh + .execute(HOST_INFO_REMOTE_PATH) + .await + .with_context(|| format!("Failed to execute host-info on {}", host.name))?; + + if exit_code != 0 { + bail!("host-info exited with code {} on {}: {}", exit_code, host.name, output); + } + + // Parse the JSON output + let host_info: HostInfoOutput = serde_json::from_str(&output) + .with_context(|| format!("Failed to parse host-info output from {}", host.name))?; + + // Update host with detected features + let cpu_mfg = match host_info.cpu_mfg.as_str() { + "intel" => CpuMfg::Intel, + "amd" => CpuMfg::Amd, + "apple" => CpuMfg::Apple, + _ => CpuMfg::Unknown, + }; + + let cpu_arch = match host_info.cpu_arch.as_str() { + "x86_64" => CpuArch::X86_64, + "arm64" => CpuArch::ARM64, + _ => CpuArch::Unknown, + }; + + // Parse CPU features + let cpu_features: Vec = host_info + .cpu_features + .iter() + .filter_map(|f| f.parse().ok()) + .collect(); + + let features_changed = host.cpu_mfg != cpu_mfg + || host.cpu_arch != cpu_arch + || host.cpu_features.0 != cpu_features; + + if features_changed { + host.cpu_mfg = cpu_mfg; + host.cpu_arch = cpu_arch; + host.cpu_features = cpu_features.into(); + self.db.update_host(host).await?; + info!( + "Updated host {} CPU info: mfg={:?}, arch={:?}, features={:?}", + host.name, host.cpu_mfg, host.cpu_arch, host.cpu_features + ); + } + + Ok(()) + } + /// Check if a domain has a DNS record pointing to the configured nostr hostname or resolves to the same IP async fn check_domain_dns(&self, domain: &str) -> Result { let Some(expected_hostname) = &self.settings.nostr_hostname else { diff --git a/lnvps_api_admin/src/admin/custom_pricing.rs b/lnvps_api_admin/src/admin/custom_pricing.rs index 8c476e14..eb21e6ed 100644 --- a/lnvps_api_admin/src/admin/custom_pricing.rs +++ b/lnvps_api_admin/src/admin/custom_pricing.rs @@ -184,6 +184,9 @@ async fn admin_create_custom_pricing( expires: req.expires, region_id: req.region_id, currency: req.currency, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), cpu_cost: req.cpu_cost, memory_cost: req.memory_cost, ip4_cost: req.ip4_cost, @@ -370,6 +373,9 @@ async fn admin_copy_custom_pricing( expires: source_pricing.expires, region_id: target_region_id, currency: source_pricing.currency, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), cpu_cost: source_pricing.cpu_cost, memory_cost: source_pricing.memory_cost, ip4_cost: source_pricing.ip4_cost, diff --git a/lnvps_api_admin/src/admin/hosts.rs b/lnvps_api_admin/src/admin/hosts.rs index a6af2932..962471c9 100644 --- a/lnvps_api_admin/src/admin/hosts.rs +++ b/lnvps_api_admin/src/admin/hosts.rs @@ -130,6 +130,12 @@ async fn admin_update_host( if let Some(load_disk) = req.load_disk { host.load_disk = load_disk; } + if let Some(ssh_user) = &req.ssh_user { + host.ssh_user = Some(ssh_user.clone()); + } + if let Some(ssh_key) = &req.ssh_key { + host.ssh_key = ssh_key.clone().map(|k| k.into()); + } // Save changes this.db.update_host(&host).await?; @@ -173,6 +179,9 @@ async fn admin_create_host( name: req.name.clone(), ip: req.ip.clone(), cpu: req.cpu, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), memory: req.memory, enabled: req.enabled.unwrap_or(true), api_token: req.api_token.clone().into(), @@ -180,6 +189,8 @@ async fn admin_create_host( load_memory: req.load_memory.unwrap_or(1.0), load_disk: req.load_disk.unwrap_or(1.0), vlan_id: req.vlan_id, + ssh_user: req.ssh_user.clone(), + ssh_key: req.ssh_key.clone().map(|k| k.into()), }; // Create host in database @@ -216,6 +227,11 @@ pub struct AdminHostUpdateRequest { pub load_cpu: Option, pub load_memory: Option, pub load_disk: Option, + /// SSH username for running host utilities (default: root) + pub ssh_user: Option, + /// SSH private key for running host utilities (PEM format) + /// Use `None` to keep existing, use `Some(None)` to clear + pub ssh_key: Option>, } #[derive(Deserialize)] @@ -232,6 +248,10 @@ pub struct AdminHostCreateRequest { pub load_cpu: Option, pub load_memory: Option, pub load_disk: Option, + /// SSH username for running host utilities (default: root) + pub ssh_user: Option, + /// SSH private key for running host utilities (PEM format) + pub ssh_key: Option, } /// List host disks diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 7f816320..593ff9d7 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -566,6 +566,10 @@ pub struct AdminHostInfo { pub disks: Vec, // Calculated load metrics pub calculated_load: CalculatedHostLoad, + /// SSH username for host utilities (None if not configured) + pub ssh_user: Option, + /// Whether SSH key is configured (key itself is not exposed) + pub ssh_key_configured: bool, } #[derive(Serialize)] @@ -645,6 +649,7 @@ pub struct UpdateRegionRequest { impl AdminHostInfo { pub fn from_host_and_region(host: lnvps_db::VmHost, region: lnvps_db::VmHostRegion) -> Self { + let ssh_key_configured = host.ssh_key.is_some(); Self { id: host.id, name: host.name, @@ -672,6 +677,8 @@ impl AdminHostInfo { available_memory: host.memory, active_vms: 0, }, + ssh_user: host.ssh_user, + ssh_key_configured, } } @@ -681,6 +688,7 @@ impl AdminHostInfo { disks: Vec, ) -> Self { let admin_disks = disks.into_iter().map(|disk| disk.into()).collect(); + let ssh_key_configured = host.ssh_key.is_some(); Self { id: host.id, @@ -709,6 +717,8 @@ impl AdminHostInfo { available_memory: host.memory, active_vms: 0, }, + ssh_user: host.ssh_user, + ssh_key_configured, } } @@ -719,6 +729,7 @@ impl AdminHostInfo { active_vms: u64, ) -> Self { let admin_disks = disks.into_iter().map(|disk| disk.into()).collect(); + let ssh_key_configured = capacity.host.ssh_key.is_some(); Self { id: capacity.host.id, @@ -747,6 +758,8 @@ impl AdminHostInfo { available_memory: capacity.available_memory(), active_vms, }, + ssh_user: capacity.host.ssh_user.clone(), + ssh_key_configured, } } @@ -757,10 +770,11 @@ impl AdminHostInfo { .into_iter() .map(|disk| disk.into()) .collect(); + let ssh_key_configured = admin_host.host.ssh_key.is_some(); Self { id: admin_host.host.id, - name: admin_host.host.name, + name: admin_host.host.name.clone(), kind: AdminVmHostKind::from(admin_host.host.kind), region: AdminHostRegion { id: admin_host.region_id, @@ -785,6 +799,8 @@ impl AdminHostInfo { available_memory: admin_host.host.memory, active_vms: admin_host.active_vm_count as _, }, + ssh_user: admin_host.host.ssh_user, + ssh_key_configured, } } @@ -805,6 +821,7 @@ impl AdminHostInfo { .into_iter() .map(|disk| disk.into()) .collect(); + let ssh_key_configured = capacity.host.ssh_key.is_some(); Self { id: capacity.host.id, @@ -833,6 +850,8 @@ impl AdminHostInfo { available_memory: capacity.available_memory(), active_vms: admin_host.active_vm_count as _, }, + ssh_user: capacity.host.ssh_user.clone(), + ssh_key_configured, } } Err(_) => { @@ -2409,7 +2428,10 @@ impl From<&lnvps_db::ProviderConfig> for SanitizedProviderConfig { api_version: cfg.api_version.clone(), public_key: cfg.public_key.clone(), has_token: !cfg.token.is_empty(), - has_webhook_secret: cfg.webhook_secret.as_ref().map_or(false, |s| !s.is_empty()), + has_webhook_secret: cfg + .webhook_secret + .as_ref() + .map_or(false, |s| !s.is_empty()), }) } lnvps_db::ProviderConfig::Stripe(cfg) => { @@ -2601,7 +2623,9 @@ impl PartialProviderConfig { (PartialProviderConfig::Lnd(partial), ProviderConfig::Lnd(existing)) => { Ok(ProviderConfig::Lnd(lnvps_db::LndConfig { url: partial.url.unwrap_or_else(|| existing.url.clone()), - cert_path: partial.cert_path.unwrap_or_else(|| existing.cert_path.clone()), + cert_path: partial + .cert_path + .unwrap_or_else(|| existing.cert_path.clone()), macaroon_path: partial .macaroon_path .unwrap_or_else(|| existing.macaroon_path.clone()), diff --git a/lnvps_api_admin/src/admin/vm_templates.rs b/lnvps_api_admin/src/admin/vm_templates.rs index 9ee04519..3ab38bce 100644 --- a/lnvps_api_admin/src/admin/vm_templates.rs +++ b/lnvps_api_admin/src/admin/vm_templates.rs @@ -157,6 +157,9 @@ async fn admin_create_vm_template( created: Utc::now(), expires: req.expires, cpu: req.cpu, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), memory: req.memory, disk_size: req.disk_size, disk_type: req.disk_type.into(), diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 814ea755..0ef4ee39 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -352,6 +352,9 @@ async fn create_hosts(db: &LNVpsDbMysql, regions: &[VmHostRegion]) -> Result Result)> = vec![ + let pricing_data: Vec<( + &str, + u64, + &str, + u64, + u64, + u64, + u64, + i32, + i32, + u64, + u64, + DateTime, + )> = vec![ ( "US-East Flex Pricing", regions[0].id, "BTC", - 5_000_000, // cpu_cost: 0.00005 BTC = 5000 sats = 5,000,000 millisats per CPU - 250_000, // memory_cost: 0.0000025 BTC = 250 sats = 250,000 millisats per GB - 10_000_000, // ip4_cost: 0.0001 BTC = 10000 sats = 10,000,000 millisats per IPv4 - 5_000_000, // ip6_cost: 0.00005 BTC = 5000 sats = 5,000,000 millisats per IPv6 + 5_000_000, // cpu_cost: 0.00005 BTC = 5000 sats = 5,000,000 millisats per CPU + 250_000, // memory_cost: 0.0000025 BTC = 250 sats = 250,000 millisats per GB + 10_000_000, // ip4_cost: 0.0001 BTC = 10000 sats = 10,000,000 millisats per IPv4 + 5_000_000, // ip6_cost: 0.00005 BTC = 5000 sats = 5,000,000 millisats per IPv6 1, 32, 1073741824u64, @@ -824,10 +845,10 @@ async fn create_custom_pricing( "EU-Central GDPR Compliant", regions[2].id, "EUR", - 14, // cpu_cost: €0.135 ≈ 14 cents per CPU - 1, // memory_cost: €0.0007 ≈ 1 cent per GB (rounded up) - 5, // ip4_cost: €0.045 ≈ 5 cents per IPv4 - 2, // ip6_cost: €0.0225 ≈ 2 cents per IPv6 + 14, // cpu_cost: €0.135 ≈ 14 cents per CPU + 1, // memory_cost: €0.0007 ≈ 1 cent per GB (rounded up) + 5, // ip4_cost: €0.045 ≈ 5 cents per IPv4 + 2, // ip6_cost: €0.0225 ≈ 2 cents per IPv6 1, 40, 1073741824u64, @@ -838,10 +859,10 @@ async fn create_custom_pricing( "Asia-Pacific Budget", regions[4].id, "USD", - 8, // cpu_cost: $0.08 = 8 cents per CPU - 1, // memory_cost: $0.00005 ≈ 0 cents (min 1) - 3, // ip4_cost: $0.03 = 3 cents per IPv4 - 2, // ip6_cost: $0.015 ≈ 2 cents per IPv6 + 8, // cpu_cost: $0.08 = 8 cents per CPU + 1, // memory_cost: $0.00005 ≈ 0 cents (min 1) + 3, // ip4_cost: $0.03 = 3 cents per IPv4 + 2, // ip6_cost: $0.015 ≈ 2 cents per IPv6 1, 24, 1073741824u64, @@ -852,10 +873,10 @@ async fn create_custom_pricing( "Canada Premium", regions[5].id, "USD", - 15, // cpu_cost: $0.15 = 15 cents per CPU - 1, // memory_cost: $0.00008 ≈ 0 cents (min 1) - 5, // ip4_cost: $0.05 = 5 cents per IPv4 - 3, // ip6_cost: $0.025 ≈ 3 cents per IPv6 + 15, // cpu_cost: $0.15 = 15 cents per CPU + 1, // memory_cost: $0.00008 ≈ 0 cents (min 1) + 5, // ip4_cost: $0.05 = 5 cents per IPv4 + 3, // ip6_cost: $0.025 ≈ 3 cents per IPv6 2, 48, 2147483648u64, @@ -866,10 +887,10 @@ async fn create_custom_pricing( "EU-North Lightning", regions[7].id, "BTC", - 8_000_000, // cpu_cost: 0.00008 BTC = 8000 sats = 8,000,000 millisats per CPU - 400_000, // memory_cost: 0.000004 BTC = 400 sats = 400,000 millisats per GB - 15_000_000, // ip4_cost: 0.00015 BTC = 15000 sats = 15,000,000 millisats per IPv4 - 8_000_000, // ip6_cost: 0.00008 BTC = 8000 sats = 8,000,000 millisats per IPv6 + 8_000_000, // cpu_cost: 0.00008 BTC = 8000 sats = 8,000,000 millisats per CPU + 400_000, // memory_cost: 0.000004 BTC = 400 sats = 400,000 millisats per GB + 15_000_000, // ip4_cost: 0.00015 BTC = 15000 sats = 15,000,000 millisats per IPv4 + 8_000_000, // ip6_cost: 0.00008 BTC = 8000 sats = 8,000,000 millisats per IPv6 1, 64, 1073741824u64, @@ -880,10 +901,10 @@ async fn create_custom_pricing( "US-Central Enterprise", regions[6].id, "USD", - 25, // cpu_cost: $0.25 = 25 cents per CPU - 1, // memory_cost: $0.00012 ≈ 0 cents (min 1) - 8, // ip4_cost: $0.08 = 8 cents per IPv4 - 4, // ip6_cost: $0.04 = 4 cents per IPv6 + 25, // cpu_cost: $0.25 = 25 cents per CPU + 1, // memory_cost: $0.00012 ≈ 0 cents (min 1) + 8, // ip4_cost: $0.08 = 8 cents per IPv4 + 4, // ip6_cost: $0.04 = 4 cents per IPv6 4, 128, 8589934592u64, @@ -916,6 +937,9 @@ async fn create_custom_pricing( expires: None, region_id, currency: currency.to_string(), + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), cpu_cost, memory_cost, ip4_cost, @@ -969,6 +993,9 @@ async fn create_custom_templates( disk_type, disk_interface, pricing_id, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), }; let id = db.insert_custom_vm_template(&template).await?; diff --git a/lnvps_api_admin/src/lib.rs b/lnvps_api_admin/src/lib.rs index 7e179c75..d7abfc70 100644 --- a/lnvps_api_admin/src/lib.rs +++ b/lnvps_api_admin/src/lib.rs @@ -1,4 +1,2 @@ -#![allow(dead_code)] - pub mod admin; pub mod settings; diff --git a/lnvps_api_common/src/capacity.rs b/lnvps_api_common/src/capacity.rs index 000bf6d0..4af892b5 100644 --- a/lnvps_api_common/src/capacity.rs +++ b/lnvps_api_common/src/capacity.rs @@ -4,8 +4,8 @@ use chrono::Utc; use futures::future::join_all; use ipnetwork::{IpNetwork, NetworkSize}; use lnvps_db::{ - DbResult, DiskInterface, DiskType, IpRange, LNVpsDb, VmCustomTemplate, VmHost, VmHostDisk, - VmIpAssignment, VmTemplate, + CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, IpRange, LNVpsDb, VmCustomTemplate, + VmHost, VmHostDisk, VmIpAssignment, VmTemplate, }; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -102,9 +102,35 @@ impl HostCapacityService { // Now apply the calculated limits to each template in place for template in &mut templates { - let hosts_in_region = caps - .iter() - .filter(|c| c.host.region_id == template.region.id); + // Filter hosts by region and CPU requirements + let hosts_in_region = caps.iter().filter(|c| { + if c.host.region_id != template.region.id { + return false; + } + // Check CPU manufacturer match (None means any) + if let Some(ref mfg) = template.cpu_mfg { + if c.host.cpu_mfg != CpuMfg::Unknown && c.host.cpu_mfg.to_string() != *mfg { + return false; + } + } + // Check CPU architecture match (None means any) + if let Some(ref arch) = template.cpu_arch { + if c.host.cpu_arch != CpuArch::Unknown && c.host.cpu_arch.to_string() != *arch { + return false; + } + } + // Check CPU features (empty list means any) + if !template.cpu_features.is_empty() { + let has_all = template + .cpu_features + .iter() + .all(|f| c.host.cpu_features.iter().any(|hf| hf.to_string() == *f)); + if !has_all { + return false; + } + } + true + }); let max_cpu = hosts_in_region .clone() .map(|h| h.available_cpu()) @@ -339,7 +365,23 @@ impl HostCapacity { /// Can this host and its available capacity accommodate the given template pub fn can_accommodate(&self, template: &impl Template) -> bool { - self.available_cpu() >= template.cpu() + // Check cpu manufacturer match (Unknown means any) + let mfg_ok = template.cpu_mfg() == CpuMfg::Unknown + || self.host.cpu_mfg == template.cpu_mfg(); + // Check cpu architecture match (Unknown means any) + let arch_ok = template.cpu_arch() == CpuArch::Unknown + || self.host.cpu_arch == template.cpu_arch(); + // Check that the host has all required CPU features (empty list means any) + let features_ok = template.cpu_features().is_empty() + || template + .cpu_features() + .iter() + .all(|f| self.host.cpu_features.contains(f)); + + mfg_ok + && arch_ok + && features_ok + && self.available_cpu() >= template.cpu() && self.available_memory() >= template.memory() && self .disks @@ -401,7 +443,8 @@ impl IPRangeCapacity { mod tests { use super::*; use crate::mock::MockDb; - use lnvps_db::LNVpsDbBase; + use crate::GB; + use lnvps_db::{CpuFeature, DiskInterface, DiskType, LNVpsDbBase}; #[test] fn loads() { @@ -508,4 +551,197 @@ mod tests { } Ok(()) } + + // ── CPU filtering tests ────────────────────────────────────────────────── + + /// Helper to create a minimal VmTemplate for testing CPU filtering + fn make_template( + cpu_mfg: CpuMfg, + cpu_arch: CpuArch, + cpu_features: Vec, + ) -> VmTemplate { + VmTemplate { + id: 99, + name: "test-template".to_string(), + enabled: true, + cpu: 1, + cpu_mfg, + cpu_arch, + cpu_features: cpu_features.into(), + memory: GB, + disk_size: GB, + disk_type: DiskType::SSD, + disk_interface: DiskInterface::PCIe, + region_id: 1, + ..Default::default() + } + } + + /// Helper to create a HostCapacity with specific CPU fields + fn make_host_capacity( + cpu_mfg: CpuMfg, + cpu_arch: CpuArch, + cpu_features: Vec, + ) -> HostCapacity { + HostCapacity { + load_factor: LoadFactors { + cpu: 1.0, + memory: 1.0, + disk: 1.0, + }, + host: VmHost { + id: 1, + region_id: 1, + cpu: 4, + cpu_mfg, + cpu_arch, + cpu_features: cpu_features.into(), + memory: 8 * GB, + enabled: true, + ..Default::default() + }, + cpu: 0, + memory: 0, + disks: vec![DiskCapacity { + load_factor: 1.0, + disk: VmHostDisk { + id: 1, + host_id: 1, + size: 100 * GB, + kind: DiskType::SSD, + interface: DiskInterface::PCIe, + ..Default::default() + }, + usage: 0, + }], + ranges: vec![IPRangeCapacity { + range: IpRange { + id: 1, + cidr: "10.0.0.0/24".to_string(), + gateway: "10.0.0.1".to_string(), + enabled: true, + region_id: 1, + ..Default::default() + }, + usage: 0, + }], + } + } + + /// Template with Unknown cpu_mfg should match any host + #[test] + fn can_accommodate_unknown_mfg_matches_any() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![]); + let template = make_template(CpuMfg::Unknown, CpuArch::Unknown, vec![]); + assert!(cap.can_accommodate(&template)); + } + + /// Template requesting Intel should match Intel host + #[test] + fn can_accommodate_matching_mfg() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![]); + let template = make_template(CpuMfg::Intel, CpuArch::Unknown, vec![]); + assert!(cap.can_accommodate(&template)); + } + + /// Template requesting AMD should NOT match Intel host + #[test] + fn can_accommodate_mismatched_mfg() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![]); + let template = make_template(CpuMfg::Amd, CpuArch::Unknown, vec![]); + assert!(!cap.can_accommodate(&template)); + } + + /// Template requesting X86_64 should match X86_64 host + #[test] + fn can_accommodate_matching_arch() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![]); + let template = make_template(CpuMfg::Unknown, CpuArch::X86_64, vec![]); + assert!(cap.can_accommodate(&template)); + } + + /// Template requesting ARM64 should NOT match X86_64 host + #[test] + fn can_accommodate_mismatched_arch() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![]); + let template = make_template(CpuMfg::Unknown, CpuArch::ARM64, vec![]); + assert!(!cap.can_accommodate(&template)); + } + + /// Template with no required features should match any host + #[test] + fn can_accommodate_empty_features_matches_any() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![CpuFeature::AVX2]); + let template = make_template(CpuMfg::Unknown, CpuArch::Unknown, vec![]); + assert!(cap.can_accommodate(&template)); + } + + /// Template requiring AVX2 should match host with AVX2 + #[test] + fn can_accommodate_matching_features() { + let cap = make_host_capacity( + CpuMfg::Intel, + CpuArch::X86_64, + vec![CpuFeature::AVX, CpuFeature::AVX2], + ); + let template = make_template(CpuMfg::Unknown, CpuArch::Unknown, vec![CpuFeature::AVX2]); + assert!(cap.can_accommodate(&template)); + } + + /// Template requiring AVX512F should NOT match host with only AVX2 + #[test] + fn can_accommodate_missing_features() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![CpuFeature::AVX2]); + let template = + make_template(CpuMfg::Unknown, CpuArch::Unknown, vec![CpuFeature::AVX512F]); + assert!(!cap.can_accommodate(&template)); + } + + /// Template requiring multiple features should match host with all of them + #[test] + fn can_accommodate_multiple_features_all_present() { + let cap = make_host_capacity( + CpuMfg::Intel, + CpuArch::X86_64, + vec![CpuFeature::AVX, CpuFeature::AVX2, CpuFeature::AES], + ); + let template = make_template( + CpuMfg::Unknown, + CpuArch::Unknown, + vec![CpuFeature::AVX, CpuFeature::AES], + ); + assert!(cap.can_accommodate(&template)); + } + + /// Template requiring multiple features should NOT match host missing one + #[test] + fn can_accommodate_multiple_features_one_missing() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![CpuFeature::AVX]); + let template = make_template( + CpuMfg::Unknown, + CpuArch::Unknown, + vec![CpuFeature::AVX, CpuFeature::AES], + ); + assert!(!cap.can_accommodate(&template)); + } + + /// Combined: Intel + X86_64 + AVX2 should match when all requirements met + #[test] + fn can_accommodate_combined_requirements_match() { + let cap = make_host_capacity( + CpuMfg::Intel, + CpuArch::X86_64, + vec![CpuFeature::AVX, CpuFeature::AVX2], + ); + let template = make_template(CpuMfg::Intel, CpuArch::X86_64, vec![CpuFeature::AVX2]); + assert!(cap.can_accommodate(&template)); + } + + /// Combined: AMD + X86_64 should NOT match Intel host even with correct arch + #[test] + fn can_accommodate_combined_requirements_mfg_mismatch() { + let cap = make_host_capacity(CpuMfg::Intel, CpuArch::X86_64, vec![CpuFeature::AVX2]); + let template = make_template(CpuMfg::Amd, CpuArch::X86_64, vec![]); + assert!(!cap.can_accommodate(&template)); + } } diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 05e4108f..b0cca176 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -3,8 +3,8 @@ use anyhow::{Context, anyhow}; use chrono::{TimeDelta, Utc}; use lnvps_db::nostr::LNVPSNostrDb; use lnvps_db::{ - AccessPolicy, AvailableIpSpace, Company, DbResult, DiskInterface, DiskType, IpRange, - IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, NostrDomain, + AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, + IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, NostrDomain, NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Router, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, @@ -61,7 +61,7 @@ impl MockDb { id: 1, name: "mock".to_string(), created: Utc::now(), - amount: 132, // 132 cents = €1.32 (in smallest currency units) + amount: 132, // 132 cents = €1.32 (in smallest currency units) currency: "EUR".to_string(), // This can be overridden based on company config interval_amount: 1, interval_type: VmCostPlanIntervalType::Month, @@ -76,6 +76,9 @@ impl MockDb { created: Utc::now(), expires: None, cpu: 2, + cpu_mfg: CpuMfg::Unknown, + cpu_arch: CpuArch::Unknown, + cpu_features: Default::default(), memory: crate::GB * 2, disk_size: crate::GB * 64, disk_type: DiskType::SSD, @@ -153,6 +156,9 @@ impl Default for MockDb { name: "mock-host".to_string(), ip: "https://localhost".to_string(), cpu: 4, + cpu_mfg: CpuMfg::Intel, + cpu_arch: CpuArch::X86_64, + cpu_features: Default::default(), memory: 8 * crate::GB, enabled: true, api_token: "".into(), @@ -160,6 +166,8 @@ impl Default for MockDb { load_memory: 2.0, load_disk: 3.0, vlan_id: Some(100), + ssh_user: None, + ssh_key: None, }, ); let mut host_disks = HashMap::new(); @@ -426,7 +434,7 @@ impl LNVpsDbBase for MockDb { Ok(id) } - async fn list_host_disks(&self, _tb: u64) -> DbResult> { + async fn list_host_disks(&self, _TB: u64) -> DbResult> { let disks = self.host_disks.lock().await; Ok(disks.values().filter(|d| d.enabled).cloned().collect()) } @@ -802,7 +810,7 @@ impl LNVpsDbBase for MockDb { .cloned()) } - async fn list_custom_pricing(&self, _tb: u64) -> DbResult> { + async fn list_custom_pricing(&self, _TB: u64) -> DbResult> { let p = self.custom_pricing.lock().await; Ok(p.values().filter(|p| p.enabled).cloned().collect()) } @@ -1069,7 +1077,7 @@ impl LNVpsDbBase for MockDb { .ok_or_else(|| anyhow!("Subscription not found with external_id: {}", ext_id))?) } - async fn insert_subscription(&self, _subscription: &Subscription) -> DbResult { + async fn insert_subscription(&self, subscription: &Subscription) -> DbResult { Ok(0) } @@ -1288,101 +1296,101 @@ impl LNVpsDbBase for MockDb { todo!() } - async fn get_available_ip_space(&self, _id: u64) -> DbResult { + async fn get_available_ip_space(&self, id: u64) -> DbResult { todo!() } - async fn get_available_ip_space_by_cidr(&self, _cidr: &str) -> DbResult { + async fn get_available_ip_space_by_cidr(&self, cidr: &str) -> DbResult { todo!() } - async fn insert_available_ip_space(&self, _space: &AvailableIpSpace) -> DbResult { + async fn insert_available_ip_space(&self, space: &AvailableIpSpace) -> DbResult { todo!() } - async fn update_available_ip_space(&self, _space: &AvailableIpSpace) -> DbResult<()> { + async fn update_available_ip_space(&self, space: &AvailableIpSpace) -> DbResult<()> { todo!() } - async fn delete_available_ip_space(&self, _id: u64) -> DbResult<()> { + async fn delete_available_ip_space(&self, id: u64) -> DbResult<()> { todo!() } async fn list_ip_space_pricing_by_space( &self, - _available_ip_space_id: u64, + available_ip_space_id: u64, ) -> DbResult> { todo!() } - async fn get_ip_space_pricing(&self, _id: u64) -> DbResult { + async fn get_ip_space_pricing(&self, id: u64) -> DbResult { todo!() } async fn get_ip_space_pricing_by_prefix( &self, - _available_ip_space_id: u64, - _prefix_size: u16, + available_ip_space_id: u64, + prefix_size: u16, ) -> DbResult { todo!() } - async fn insert_ip_space_pricing(&self, _pricing: &IpSpacePricing) -> DbResult { + async fn insert_ip_space_pricing(&self, pricing: &IpSpacePricing) -> DbResult { todo!() } - async fn update_ip_space_pricing(&self, _pricing: &IpSpacePricing) -> DbResult<()> { + async fn update_ip_space_pricing(&self, pricing: &IpSpacePricing) -> DbResult<()> { todo!() } - async fn delete_ip_space_pricing(&self, _id: u64) -> DbResult<()> { + async fn delete_ip_space_pricing(&self, id: u64) -> DbResult<()> { todo!() } async fn list_ip_range_subscriptions_by_line_item( &self, - _subscription_line_item_id: u64, + subscription_line_item_id: u64, ) -> DbResult> { todo!() } async fn list_ip_range_subscriptions_by_subscription( &self, - _subscription_id: u64, + subscription_id: u64, ) -> DbResult> { todo!() } async fn list_ip_range_subscriptions_by_user( &self, - _user_id: u64, + user_id: u64, ) -> DbResult> { todo!() } - async fn get_ip_range_subscription(&self, _id: u64) -> DbResult { + async fn get_ip_range_subscription(&self, id: u64) -> DbResult { todo!() } - async fn get_ip_range_subscription_by_cidr(&self, _cidr: &str) -> DbResult { + async fn get_ip_range_subscription_by_cidr(&self, cidr: &str) -> DbResult { todo!() } async fn insert_ip_range_subscription( &self, - _subscription: &IpRangeSubscription, + subscription: &IpRangeSubscription, ) -> DbResult { todo!() } async fn update_ip_range_subscription( &self, - _subscription: &IpRangeSubscription, + subscription: &IpRangeSubscription, ) -> DbResult<()> { todo!() } - async fn delete_ip_range_subscription(&self, _id: u64) -> DbResult<()> { + async fn delete_ip_range_subscription(&self, id: u64) -> DbResult<()> { todo!() } diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index eed7938c..5aac1a56 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -5,8 +5,8 @@ use chrono::{DateTime, Utc}; use futures::future::join_all; use ipnetwork::IpNetwork; use lnvps_db::{ - IpRange, LNVpsDb, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, - VmHostRegion, VmTemplate, + CpuArch, CpuFeature, CpuMfg, IpRange, LNVpsDb, Vm, VmCostPlan, VmCustomPricing, + VmCustomPricingDisk, VmCustomTemplate, VmHostRegion, VmTemplate, }; use payments_rs::currency::{Currency, CurrencyAmount}; use serde::{Deserialize, Serialize}; @@ -20,6 +20,12 @@ pub trait Template { fn disk_size(&self) -> u64; fn disk_type(&self) -> lnvps_db::DiskType; fn disk_interface(&self) -> lnvps_db::DiskInterface; + /// Requested CPU manufacturer. [`CpuMfg::Unknown`] means "any". + fn cpu_mfg(&self) -> CpuMfg; + /// Requested CPU architecture. [`CpuArch::Unknown`] means "any". + fn cpu_arch(&self) -> CpuArch; + /// Required CPU feature flags. An empty list means "any". + fn cpu_features(&self) -> &[CpuFeature]; } impl Template for VmTemplate { @@ -42,6 +48,18 @@ impl Template for VmTemplate { fn disk_interface(&self) -> lnvps_db::DiskInterface { self.disk_interface } + + fn cpu_mfg(&self) -> CpuMfg { + self.cpu_mfg.clone() + } + + fn cpu_arch(&self) -> CpuArch { + self.cpu_arch.clone() + } + + fn cpu_features(&self) -> &[CpuFeature] { + &self.cpu_features + } } impl Template for VmCustomTemplate { @@ -64,6 +82,18 @@ impl Template for VmCustomTemplate { fn disk_interface(&self) -> lnvps_db::DiskInterface { self.disk_interface } + + fn cpu_mfg(&self) -> CpuMfg { + self.cpu_mfg.clone() + } + + fn cpu_arch(&self) -> CpuArch { + self.cpu_arch.clone() + } + + fn cpu_features(&self) -> &[CpuFeature] { + &self.cpu_features + } } impl ApiVmTemplate { @@ -85,6 +115,21 @@ impl ApiVmTemplate { created: pricing.created, expires: pricing.expires, cpu: template.cpu, + cpu_features: template + .cpu_features + .iter() + .map(|x| x.to_string()) + .collect(), + cpu_mfg: if matches!(template.cpu_mfg, CpuMfg::Unknown) { + None + } else { + Some(template.cpu_mfg.to_string()) + }, + cpu_arch: if matches!(template.cpu_arch, CpuArch::Unknown) { + None + } else { + Some(template.cpu_arch.to_string()) + }, memory: template.memory, disk_size: template.disk_size, disk_type: template.disk_type.into(), @@ -126,6 +171,21 @@ impl ApiVmTemplate { created: template.created, expires: template.expires, cpu: template.cpu, + cpu_features: template + .cpu_features + .iter() + .map(|x| x.to_string()) + .collect(), + cpu_mfg: if matches!(template.cpu_mfg, CpuMfg::Unknown) { + None + } else { + Some(template.cpu_mfg.to_string()) + }, + cpu_arch: if matches!(template.cpu_arch, CpuArch::Unknown) { + None + } else { + Some(template.cpu_arch.to_string()) + }, memory: template.memory, disk_size: template.disk_size, disk_type: template.disk_type.into(), @@ -329,6 +389,12 @@ pub struct ApiVmTemplate { pub disk_interface: ApiDiskInterface, pub cost_plan: ApiVmCostPlan, pub region: ApiVmHostRegion, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub cpu_features: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_mfg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_arch: Option, } #[derive(Serialize, Deserialize, Clone, Copy)] @@ -540,6 +606,12 @@ pub struct ApiCustomTemplateParams { pub id: u64, pub name: String, pub region: ApiVmHostRegion, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub cpu_features: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub cpu_mfg: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub cpu_arch: Option, pub max_cpu: u16, pub min_cpu: u16, pub min_memory: u64, @@ -560,6 +632,21 @@ impl ApiCustomTemplateParams { id: region.id, name: region.name.clone(), }, + cpu_features: pricing + .cpu_features + .iter() + .map(ToString::to_string) + .collect(), + cpu_mfg: if matches!(pricing.cpu_mfg, CpuMfg::Unknown) { + None + } else { + Some(pricing.cpu_mfg.to_string()) + }, + cpu_arch: if matches!(pricing.cpu_arch, CpuArch::Unknown) { + None + } else { + Some(pricing.cpu_arch.to_string()) + }, max_cpu: pricing.max_cpu, min_cpu: pricing.min_cpu, min_memory: pricing.min_memory, diff --git a/lnvps_api_common/src/nip98.rs b/lnvps_api_common/src/nip98.rs index fd004c98..454de142 100644 --- a/lnvps_api_common/src/nip98.rs +++ b/lnvps_api_common/src/nip98.rs @@ -20,8 +20,8 @@ impl Nip98Auth { if self .event .created_at - .as_secs() - .abs_diff(Timestamp::now().as_secs()) + .as_u64() + .abs_diff(Timestamp::now().as_u64()) > 600 { bail!("Created timestamp is out of range"); diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 06317661..e2802155 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -4,8 +4,8 @@ use chrono::{DateTime, Days, Months, TimeDelta, Utc}; use ipnetwork::IpNetwork; use isocountry::CountryCode; use lnvps_db::{ - DiskInterface, DiskType, LNVpsDb, PaymentMethod, PaymentType, Vm, VmCostPlan, - VmCostPlanIntervalType, VmCustomPricing, VmCustomTemplate, VmPayment, + CpuArch, CpuFeature, CpuMfg, DiskInterface, DiskType, LNVpsDb, PaymentMethod, PaymentType, Vm, + VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomTemplate, VmPayment, }; use payments_rs::currency::{Currency, CurrencyAmount}; use std::collections::HashMap; @@ -13,7 +13,6 @@ use std::ops::{Add, Sub}; use std::str::FromStr; use std::sync::Arc; - /// Result of calculating upgrade costs including both immediate upgrade cost and new renewal cost #[derive(Debug, Clone)] pub struct UpgradeCostQuote { @@ -47,7 +46,6 @@ pub struct PricingEngine { db: Arc, rates: Arc, tax_rates: HashMap, - #[allow(dead_code)] base_currency: Currency, } @@ -64,7 +62,7 @@ impl PricingEngine { }; base_seconds * interval_amount as i64 } - + /// Calculate processing fee for a payment based on payment method and amount /// Returns the processing fee in the same currency as the amount /// Queries the database for fee configuration @@ -81,16 +79,28 @@ impl PricingEngine { } // Try to get fee config from database - let config = match self.db.get_payment_method_config_for_company(company_id, method).await { + let config = match self + .db + .get_payment_method_config_for_company(company_id, method) + .await + { Ok(config) => config, Err(e) => { - log::warn!("Failed to load payment method config for company {} method {:?}: {}", company_id, method, e); + log::warn!( + "Failed to load payment method config for company {} method {:?}: {}", + company_id, + method, + e + ); return 0; } }; // Check if config is enabled and has fee settings - if !config.enabled || config.processing_fee_rate.is_none() || config.processing_fee_base.is_none() { + if !config.enabled + || config.processing_fee_rate.is_none() + || config.processing_fee_base.is_none() + { return 0; } @@ -102,7 +112,8 @@ impl PricingEngine { // => fee = amount * rate / (1 - rate) // This ensures we net exactly `amount` after the provider deducts their cut. let rate_fraction = rate as f64 / 100.0; - let percentage_fee = ((amount as f64) * rate_fraction / (1.0 - rate_fraction)).ceil() as u64; + let percentage_fee = + ((amount as f64) * rate_fraction / (1.0 - rate_fraction)).ceil() as u64; // Get base fee, converting currency if needed let base_fee_currency = Currency::from_str(base_currency_str).unwrap_or_else(|_| { @@ -136,7 +147,7 @@ impl PricingEngine { percentage_fee + base_fee } - + pub fn new( db: Arc, rates: Arc, @@ -196,7 +207,9 @@ impl PricingEngine { new_expiry: vm.expires.add(TimeDelta::seconds(new_time as i64)), rate: cost.rate, tax: self.get_tax_for_user(vm.user_id, input.value()).await?, - processing_fee: self.calculate_processing_fee(company_id, method, cost.currency, input.value()).await, + processing_fee: self + .calculate_processing_fee(company_id, method, cost.currency, input.value()) + .await, })) } @@ -243,10 +256,10 @@ impl PricingEngine { } else { let scaled_amount = base_cost.amount * intervals as u64; let scaled_time = expected_time_value; - let scaled_tax = self - .get_tax_for_user(vm.user_id, scaled_amount) - .await?; - let processing_fee = self.calculate_processing_fee(company_id, method, base_cost.currency, scaled_amount).await; + let scaled_tax = self.get_tax_for_user(vm.user_id, scaled_amount).await?; + let processing_fee = self + .calculate_processing_fee(company_id, method, base_cost.currency, scaled_amount) + .await; Ok(CostResult::New(NewPaymentInfo { amount: scaled_amount, tax: scaled_tax, @@ -295,7 +308,7 @@ impl PricingEngine { // All costs are in smallest currency units (cents/millisats) let disk_size_gb = template.disk_size / crate::GB; let memory_gb = template.memory / crate::GB; - + let disk_cost = disk_size_gb * disk_pricing.cost; let cpu_cost = pricing.cpu_cost * template.cpu as u64; let memory_cost = pricing.memory_cost * memory_gb; @@ -318,7 +331,12 @@ impl PricingEngine { } /// Get the renewal cost of a custom VM - async fn get_custom_vm_cost(&self, vm: &Vm, method: PaymentMethod, company_id: u64) -> Result { + async fn get_custom_vm_cost( + &self, + vm: &Vm, + method: PaymentMethod, + company_id: u64, + ) -> Result { let template_id = if let Some(i) = vm.custom_template_id { i } else { @@ -341,7 +359,14 @@ impl PricingEngine { tax: self .get_tax_for_user(vm.user_id, converted_amount.amount.value()) .await?, - processing_fee: self.calculate_processing_fee(company_id, method, converted_amount.amount.currency(), converted_amount.amount.value()).await, + processing_fee: self + .calculate_processing_fee( + company_id, + method, + converted_amount.amount.currency(), + converted_amount.amount.value(), + ) + .await, currency: converted_amount.amount.currency(), rate: converted_amount.rate, time_value, @@ -396,7 +421,12 @@ impl PricingEngine { } /// Gets the renewal cost of a standard VM - async fn get_template_vm_cost(&self, vm: &Vm, method: PaymentMethod, company_id: u64) -> Result { + async fn get_template_vm_cost( + &self, + vm: &Vm, + method: PaymentMethod, + company_id: u64, + ) -> Result { let template_id = if let Some(i) = vm.template_id { i } else { @@ -415,7 +445,14 @@ impl PricingEngine { tax: self .get_tax_for_user(vm.user_id, converted_amount.amount.value()) .await?, - processing_fee: self.calculate_processing_fee(company_id, method, converted_amount.amount.currency(), converted_amount.amount.value()).await, + processing_fee: self + .calculate_processing_fee( + company_id, + method, + converted_amount.amount.currency(), + converted_amount.amount.value(), + ) + .await, currency: converted_amount.amount.currency(), rate: converted_amount.rate, time_value, @@ -428,6 +465,9 @@ impl PricingEngine { region_id: u64, disk_type: DiskType, disk_interface: DiskInterface, + cpu_mfg: CpuMfg, + cpu_arch: CpuArch, + cpu_features: &[CpuFeature], ) -> Result { // Get custom pricing for the region let custom_pricings = self.db.list_custom_pricing(region_id).await?; @@ -438,6 +478,30 @@ impl PricingEngine { continue; } + // Check CPU manufacturer match (Unknown means any) + if cpu_mfg != CpuMfg::Unknown && pricing.cpu_mfg != CpuMfg::Unknown { + if pricing.cpu_mfg != cpu_mfg { + continue; + } + } + + // Check CPU architecture match (Unknown means any) + if cpu_arch != CpuArch::Unknown && pricing.cpu_arch != CpuArch::Unknown { + if pricing.cpu_arch != cpu_arch { + continue; + } + } + + // Check that pricing supports all required CPU features (empty list means any) + if !cpu_features.is_empty() && !pricing.cpu_features.is_empty() { + let has_all_features = cpu_features + .iter() + .all(|f| pricing.cpu_features.contains(f)); + if !has_all_features { + continue; + } + } + // Check if this pricing supports the required disk type and interface let disk_configs = self.db.list_custom_pricing_disk(pricing.id).await?; let has_compatible_disk = disk_configs @@ -468,37 +532,55 @@ impl PricingEngine { let vm = self.db.get_vm(vm_id).await?; // find a custom pricing model for the vm - let (pricing, cpu, memory, disk, disk_type, disk_interface) = - if let Some(template_id) = vm.template_id { - let template = self.db.get_vm_template(template_id).await?; - ( - self.find_custom_pricing( - template.region_id, - template.disk_type, - template.disk_interface, - ) - .await?, - template.cpu, - template.memory, - template.disk_size, + let ( + pricing, + cpu, + memory, + disk, + disk_type, + disk_interface, + cpu_mfg, + cpu_arch, + cpu_features, + ) = if let Some(template_id) = vm.template_id { + let template = self.db.get_vm_template(template_id).await?; + ( + self.find_custom_pricing( + template.region_id, template.disk_type, template.disk_interface, + template.cpu_mfg.clone(), + template.cpu_arch.clone(), + &template.cpu_features, ) - } else if let Some(custom_template_id) = vm.custom_template_id { - let custom_template = self.db.get_custom_vm_template(custom_template_id).await?; - ( - self.db - .get_custom_pricing(custom_template.pricing_id) - .await?, - custom_template.cpu, - custom_template.memory, - custom_template.disk_size, - custom_template.disk_type, - custom_template.disk_interface, - ) - } else { - bail!("VM must have either a standard template or custom template to upgrade"); - }; + .await?, + template.cpu, + template.memory, + template.disk_size, + template.disk_type, + template.disk_interface, + template.cpu_mfg, + template.cpu_arch, + template.cpu_features, + ) + } else if let Some(custom_template_id) = vm.custom_template_id { + let custom_template = self.db.get_custom_vm_template(custom_template_id).await?; + ( + self.db + .get_custom_pricing(custom_template.pricing_id) + .await?, + custom_template.cpu, + custom_template.memory, + custom_template.disk_size, + custom_template.disk_type, + custom_template.disk_interface, + custom_template.cpu_mfg, + custom_template.cpu_arch, + custom_template.cpu_features, + ) + } else { + bail!("VM must have either a standard template or custom template to upgrade"); + }; // Build the new custom template with upgraded specs let new_custom_template = VmCustomTemplate { @@ -509,6 +591,9 @@ impl PricingEngine { disk_type, disk_interface, pricing_id: pricing.id, + cpu_mfg, + cpu_arch, + cpu_features, }; Ok(new_custom_template) } @@ -732,7 +817,8 @@ mod tests { use super::*; use crate::{MockDb, MockExchangeRate}; use lnvps_db::{ - DiskType, LNVpsDbBase, PaymentMethodConfig, User, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, + CpuMfg, DiskType, LNVpsDbBase, PaymentMethodConfig, User, VmCustomPricing, + VmCustomPricingDisk, VmCustomTemplate, }; const MOCK_RATE: f32 = 100_000.0; @@ -773,10 +859,13 @@ mod tests { expires: None, region_id: 1, currency: "EUR".to_string(), - cpu_cost: 150, // €1.50 in cents per CPU core - memory_cost: 50, // €0.50 in cents per GB RAM - ip4_cost: 50, // €0.50 in cents per IPv4 - ip6_cost: 5, // €0.05 in cents per IPv6 + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), + cpu_cost: 150, // €1.50 in cents per CPU core + memory_cost: 50, // €0.50 in cents per GB RAM + ip4_cost: 50, // €0.50 in cents per IPv4 + ip6_cost: 5, // €0.05 in cents per IPv6 min_cpu: 1, max_cpu: 16, min_memory: 1 * crate::GB, @@ -794,6 +883,9 @@ mod tests { disk_type: DiskType::SSD, disk_interface: Default::default(), pricing_id: 1, + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), }, ); let mut d = db.custom_pricing_disk.lock().await; @@ -804,7 +896,7 @@ mod tests { pricing_id: 1, kind: DiskType::SSD, interface: Default::default(), - cost: 5, // €0.05 in cents per GB disk + cost: 5, // €0.05 in cents per GB disk min_disk_size: 5 * crate::GB, max_disk_size: 1 * crate::TB, }, @@ -926,7 +1018,10 @@ mod tests { let revolut_cut = (order_total as f64 * 0.028).floor() as u64 + 20; let net = order_total - revolut_cut; assert!(net >= amount, "net {net} must be >= amount {amount}"); - assert!(net <= amount + 1, "net {net} must not exceed amount {amount} by more than 1 cent"); + assert!( + net <= amount + 1, + "net {net} must not exceed amount {amount} by more than 1 cent" + ); Ok(()) } @@ -1123,6 +1218,9 @@ mod tests { expires: None, region_id: 1, currency: "EUR".to_string(), + cpu_mfg: Default::default(), + cpu_arch: Default::default(), + cpu_features: Default::default(), cpu_cost: 200, // €2.00 in cents per CPU per month memory_cost: 100, // €1.00 in cents per GB per month ip4_cost: 0, @@ -1677,7 +1775,7 @@ mod tests { async fn test_processing_fees() -> Result<()> { let db = MockDb::default(); let rates = Arc::new(MockExchangeRate::new()); - + // Set up EUR rate rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -1685,7 +1783,7 @@ mod tests { { let mut v = db.vms.lock().await; v.insert(1, MockDb::mock_vm()); - + let mut u = db.users.lock().await; u.insert( 1, @@ -1708,7 +1806,10 @@ mod tests { let price_lightning = pe.get_vm_cost(1, PaymentMethod::Lightning).await?; match price_lightning { CostResult::New(payment_info) => { - assert_eq!(0, payment_info.processing_fee, "Lightning should have no processing fee"); + assert_eq!( + 0, payment_info.processing_fee, + "Lightning should have no processing fee" + ); } _ => bail!("Expected new payment"), } @@ -1720,16 +1821,15 @@ mod tests { let plan = MockDb::mock_cost_plan(); // plan.amount is already in cents (132 cents = €1.32) let expected_amount_cents = plan.amount; - + // Processing fee gross-up: ensure we net exactly `amount` after provider takes 2.8% // percentage: ceil(132 * 0.028 / 0.972) = ceil(3.804) = 4 cents // flat: ceil(20 / 0.972) = ceil(20.576) = 21 cents // total = 25 cents let expected_fee = 25u64; - + assert_eq!( - expected_fee, - payment_info.processing_fee, + expected_fee, payment_info.processing_fee, "Revolut processing fee should be 2.8% + 0.20 EUR" ); assert_eq!(Currency::EUR, payment_info.currency, "Should be EUR"); @@ -1739,4 +1839,273 @@ mod tests { Ok(()) } + + // ── CPU filtering tests for find_custom_pricing ────────────────────────── + + use lnvps_db::{CpuArch, CpuFeature, DiskInterface, Vm}; + + /// Helper to insert a custom pricing with specific CPU requirements + async fn insert_pricing_with_cpu( + db: &MockDb, + id: u64, + cpu_mfg: CpuMfg, + cpu_arch: CpuArch, + cpu_features: Vec, + ) { + let mut p = db.custom_pricing.lock().await; + p.insert( + id, + VmCustomPricing { + id, + name: format!("pricing-{}", id), + enabled: true, + created: Utc::now(), + expires: None, + region_id: 1, + currency: "EUR".to_string(), + cpu_mfg, + cpu_arch, + cpu_features: cpu_features.into(), + cpu_cost: 150, + memory_cost: 50, + ip4_cost: 50, + ip6_cost: 5, + min_cpu: 1, + max_cpu: 16, + min_memory: crate::GB, + max_memory: 64 * crate::GB, + }, + ); + let mut d = db.custom_pricing_disk.lock().await; + d.insert( + id, + VmCustomPricingDisk { + id, + pricing_id: id, + kind: DiskType::SSD, + interface: DiskInterface::PCIe, + cost: 5, + min_disk_size: crate::GB, + max_disk_size: crate::TB, + }, + ); + } + + /// Helper to update the mock template's CPU fields + async fn set_template_cpu( + db: &MockDb, + cpu_mfg: CpuMfg, + cpu_arch: CpuArch, + cpu_features: Vec, + ) { + let mut t = db.templates.lock().await; + if let Some(template) = t.get_mut(&1) { + template.cpu_mfg = cpu_mfg; + template.cpu_arch = cpu_arch; + template.cpu_features = cpu_features.into(); + } + } + + /// Helper to insert a VM pointing at template 1 + async fn insert_vm_for_template(db: &MockDb) -> u64 { + let user_id = db.upsert_user(&[0u8; 32]).await.unwrap(); + let mut ssh_keys = db.user_ssh_keys.lock().await; + ssh_keys.insert( + 1, + lnvps_db::UserSshKey { + id: 1, + user_id, + name: "test".to_string(), + key_data: "ssh-rsa AAA".into(), + created: Utc::now(), + }, + ); + drop(ssh_keys); + + let mut vms = db.vms.lock().await; + let vm_id = 1; + vms.insert( + vm_id, + Vm { + id: vm_id, + host_id: 1, + user_id, + image_id: 1, + template_id: Some(1), + custom_template_id: None, + ssh_key_id: 1, + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + expires: Utc::now() + TimeDelta::days(30), + ..Default::default() + }, + ); + vm_id + } + + /// find_custom_pricing should match pricing with Unknown cpu_mfg to any template + #[tokio::test] + async fn test_find_custom_pricing_unknown_mfg_matches() -> Result<()> { + let db = MockDb::default(); + insert_pricing_with_cpu(&db, 1, CpuMfg::Unknown, CpuArch::Unknown, vec![]).await; + set_template_cpu(&db, CpuMfg::Intel, CpuArch::X86_64, vec![]).await; + let vm_id = insert_vm_for_template(&db).await; + + let db: Arc = Arc::new(db); + let rates = Arc::new(MockExchangeRate::new()); + let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + + let cfg = crate::UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = pe.create_upgrade_template(vm_id, &cfg).await; + assert!(result.is_ok(), "Should find pricing with Unknown cpu_mfg"); + assert_eq!(result.unwrap().pricing_id, 1); + Ok(()) + } + + /// find_custom_pricing should match pricing with matching cpu_mfg + #[tokio::test] + async fn test_find_custom_pricing_matching_mfg() -> Result<()> { + let db = MockDb::default(); + insert_pricing_with_cpu(&db, 1, CpuMfg::Intel, CpuArch::Unknown, vec![]).await; + set_template_cpu(&db, CpuMfg::Intel, CpuArch::X86_64, vec![]).await; + let vm_id = insert_vm_for_template(&db).await; + + let db: Arc = Arc::new(db); + let rates = Arc::new(MockExchangeRate::new()); + let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + + let cfg = crate::UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = pe.create_upgrade_template(vm_id, &cfg).await; + assert!( + result.is_ok(), + "Should find pricing with matching Intel cpu_mfg" + ); + assert_eq!(result.unwrap().pricing_id, 1); + Ok(()) + } + + /// find_custom_pricing should skip pricing with mismatched cpu_mfg + #[tokio::test] + async fn test_find_custom_pricing_mismatched_mfg_skipped() -> Result<()> { + let db = MockDb::default(); + // Pricing requires AMD, template is Intel + insert_pricing_with_cpu(&db, 1, CpuMfg::Amd, CpuArch::Unknown, vec![]).await; + set_template_cpu(&db, CpuMfg::Intel, CpuArch::X86_64, vec![]).await; + let vm_id = insert_vm_for_template(&db).await; + + let db: Arc = Arc::new(db); + let rates = Arc::new(MockExchangeRate::new()); + let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + + let cfg = crate::UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = pe.create_upgrade_template(vm_id, &cfg).await; + assert!( + result.is_err(), + "Should not find pricing with mismatched cpu_mfg" + ); + Ok(()) + } + + /// find_custom_pricing should select the first compatible pricing by CPU + #[tokio::test] + async fn test_find_custom_pricing_selects_first_compatible() -> Result<()> { + let db = MockDb::default(); + // First pricing: AMD only (incompatible) + insert_pricing_with_cpu(&db, 1, CpuMfg::Amd, CpuArch::Unknown, vec![]).await; + // Second pricing: Intel (compatible) + insert_pricing_with_cpu(&db, 2, CpuMfg::Intel, CpuArch::Unknown, vec![]).await; + set_template_cpu(&db, CpuMfg::Intel, CpuArch::X86_64, vec![]).await; + let vm_id = insert_vm_for_template(&db).await; + + let db: Arc = Arc::new(db); + let rates = Arc::new(MockExchangeRate::new()); + let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + + let cfg = crate::UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = pe.create_upgrade_template(vm_id, &cfg).await; + assert!(result.is_ok(), "Should find second pricing"); + assert_eq!(result.unwrap().pricing_id, 2, "Should select Intel pricing"); + Ok(()) + } + + /// find_custom_pricing should filter by cpu_arch + #[tokio::test] + async fn test_find_custom_pricing_arch_filtering() -> Result<()> { + let db = MockDb::default(); + // Pricing requires ARM64, template is X86_64 + insert_pricing_with_cpu(&db, 1, CpuMfg::Unknown, CpuArch::ARM64, vec![]).await; + set_template_cpu(&db, CpuMfg::Intel, CpuArch::X86_64, vec![]).await; + let vm_id = insert_vm_for_template(&db).await; + + let db: Arc = Arc::new(db); + let rates = Arc::new(MockExchangeRate::new()); + let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + + let cfg = crate::UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = pe.create_upgrade_template(vm_id, &cfg).await; + assert!( + result.is_err(), + "Should not find pricing with mismatched cpu_arch" + ); + Ok(()) + } + + /// find_custom_pricing should filter by cpu_features + #[tokio::test] + async fn test_find_custom_pricing_features_filtering() -> Result<()> { + let db = MockDb::default(); + // Pricing requires AVX512F, template only has AVX2 + insert_pricing_with_cpu( + &db, + 1, + CpuMfg::Unknown, + CpuArch::Unknown, + vec![CpuFeature::AVX512F], + ) + .await; + set_template_cpu( + &db, + CpuMfg::Intel, + CpuArch::X86_64, + vec![CpuFeature::AVX, CpuFeature::AVX2], + ) + .await; + let vm_id = insert_vm_for_template(&db).await; + + let db: Arc = Arc::new(db); + let rates = Arc::new(MockExchangeRate::new()); + let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + + let cfg = crate::UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + let result = pe.create_upgrade_template(vm_id, &cfg).await; + assert!( + result.is_err(), + "Should not find pricing when template lacks required features" + ); + Ok(()) + } } diff --git a/lnvps_api_common/src/status.rs b/lnvps_api_common/src/status.rs index 84624f9e..343034e1 100644 --- a/lnvps_api_common/src/status.rs +++ b/lnvps_api_common/src/status.rs @@ -90,7 +90,6 @@ impl VmStateCacheBackend for LocalVmStateCache { /// Redis-backed cache backend #[derive(Clone)] pub struct RedisVmStateCache { - #[allow(dead_code)] client: redis::Client, conn: MultiplexedConnection, ttl: Duration, diff --git a/lnvps_db/migrations/20260219000000_cpu_type.sql b/lnvps_db/migrations/20260219000000_cpu_type.sql new file mode 100644 index 00000000..f24e6fd3 --- /dev/null +++ b/lnvps_db/migrations/20260219000000_cpu_type.sql @@ -0,0 +1,28 @@ +-- Add CPU type columns to vm_host +ALTER TABLE vm_host + ADD COLUMN cpu_mfg SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_arch SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_features VARCHAR(255) NOT NULL DEFAULT ''; + +-- Add CPU type columns to vm_template +ALTER TABLE vm_template + ADD COLUMN cpu_mfg SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_arch SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_features VARCHAR(255) NOT NULL DEFAULT ''; + +-- Add CPU type columns to vm_custom_pricing +ALTER TABLE vm_custom_pricing + ADD COLUMN cpu_mfg SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_arch SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_features VARCHAR(255) NOT NULL DEFAULT ''; + +-- Add CPU type columns to vm_custom_template +ALTER TABLE vm_custom_template + ADD COLUMN cpu_mfg SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_arch SMALLINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN cpu_features VARCHAR(255) NOT NULL DEFAULT ''; + +-- Add SSH credentials to vm_host for running host utilities +ALTER TABLE vm_host + ADD COLUMN ssh_user VARCHAR(255) NULL, + ADD COLUMN ssh_key TEXT NULL; diff --git a/lnvps_db/src/comma_separated.rs b/lnvps_db/src/comma_separated.rs new file mode 100644 index 00000000..b4690a91 --- /dev/null +++ b/lnvps_db/src/comma_separated.rs @@ -0,0 +1,210 @@ +use std::fmt::{self, Display}; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// A wrapper around `Vec` that serialises to/from a comma-separated string +/// when stored in a SQL column. +/// +/// # Decode +/// The column value is split on `','`, each token is trimmed, and `T::from_str` +/// is called on each non-empty token. +/// +/// # Encode +/// Each element is formatted with `Display` and joined with `','`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CommaSeparated(pub Vec); + +impl Default for CommaSeparated { + fn default() -> Self { + Self(Vec::new()) + } +} + +impl CommaSeparated { + pub fn new(inner: Vec) -> Self { + Self(inner) + } + + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl From> for CommaSeparated { + fn from(v: Vec) -> Self { + Self(v) + } +} + +impl From> for Vec { + fn from(cs: CommaSeparated) -> Self { + cs.0 + } +} + +impl Deref for CommaSeparated { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for CommaSeparated { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Display for CommaSeparated { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for item in &self.0 { + if !first { + f.write_str(",")?; + } + write!(f, "{item}")?; + first = false; + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// sqlx MySQL implementation +// --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] +mod mysql_impl { + use super::*; + use sqlx::error::BoxDynError; + use sqlx::mysql::{MySql, MySqlTypeInfo, MySqlValueRef}; + use sqlx::{Decode, Encode, Type}; + + impl Type for CommaSeparated { + fn type_info() -> MySqlTypeInfo { + >::type_info() + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + >::compatible(ty) + } + } + + impl<'r, T> Decode<'r, MySql> for CommaSeparated + where + T: FromStr, + T::Err: Display, + { + fn decode(value: MySqlValueRef<'r>) -> Result { + use sqlx::ValueRef; + // Handle NULL as empty list + if value.is_null() { + return Ok(CommaSeparated(Vec::new())); + } + let raw = >::decode(value)?; + if raw.is_empty() { + return Ok(CommaSeparated(Vec::new())); + } + let items = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| T::from_str(s).map_err(|e| -> BoxDynError { e.to_string().into() })) + .collect::, BoxDynError>>()?; + Ok(CommaSeparated(items)) + } + } + + impl<'q, T> Encode<'q, MySql> for CommaSeparated + where + T: Display, + { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let encoded = self.to_string(); + >::encode_by_ref(&encoded, buf) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_empty() { + let cs: CommaSeparated = CommaSeparated::new(vec![]); + assert_eq!(cs.to_string(), ""); + } + + #[test] + fn display_single() { + let cs = CommaSeparated::new(vec![42u32]); + assert_eq!(cs.to_string(), "42"); + } + + #[test] + fn display_multiple() { + let cs = CommaSeparated::new(vec![1u32, 2, 3]); + assert_eq!(cs.to_string(), "1,2,3"); + } + + #[test] + fn from_vec_and_into_vec() { + let v = vec![10u32, 20, 30]; + let cs = CommaSeparated::from(v.clone()); + let back: Vec = cs.into(); + assert_eq!(back, v); + } + + #[test] + fn deref_as_slice() { + let cs = CommaSeparated::new(vec![1u32, 2, 3]); + assert_eq!(cs.len(), 3); + assert_eq!(cs[1], 2); + } + + #[test] + fn parse_via_from_str_roundtrip() { + // Simulate what Decode does: split + T::from_str + let raw = "10,20,30"; + let items: Vec = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.parse::().unwrap()) + .collect(); + let cs = CommaSeparated::new(items); + assert_eq!(cs.to_string(), raw); + } + + #[test] + fn parse_empty_string() { + let raw = ""; + let items: Vec = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.parse::().unwrap()) + .collect(); + assert!(items.is_empty()); + } + + #[test] + fn parse_with_spaces() { + let raw = " 1 , 2 , 3 "; + let items: Vec = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.parse::().unwrap()) + .collect(); + let cs = CommaSeparated::new(items); + assert_eq!(cs.to_string(), "1,2,3"); + } +} diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index ec00de3b..69690a22 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -5,6 +5,7 @@ use thiserror::Error; #[cfg(feature = "admin")] mod admin; +pub mod comma_separated; pub mod encrypted_string; pub mod encryption; mod model; @@ -17,6 +18,7 @@ pub mod nostr; use crate::nostr::LNVPSNostrDb; #[cfg(feature = "admin")] pub use admin::*; +pub use comma_separated::CommaSeparated; pub use encrypted_string::EncryptedString; pub use encryption::EncryptionContext; pub use model::*; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index cd8ccd71..5a15c3e1 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1,5 +1,6 @@ +use crate::comma_separated::CommaSeparated; use crate::encrypted_string::EncryptedString; -use anyhow::{Result, anyhow, bail}; +use anyhow::{anyhow, bail, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Type}; @@ -79,6 +80,343 @@ impl Display for VmHostKind { } } +/// CPU manufacturer +#[derive(Clone, Debug, sqlx::Type, PartialEq, Eq, Default, Copy)] +#[repr(u16)] +pub enum CpuMfg { + #[default] + Unknown = 0, + Intel = 1, + Amd = 2, + Apple = 3, + Nvidia = 4, + Arm = 5, +} + +impl Display for CpuMfg { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CpuMfg::Unknown => write!(f, "unknown"), + CpuMfg::Intel => write!(f, "intel"), + CpuMfg::Amd => write!(f, "amd"), + CpuMfg::Apple => write!(f, "apple"), + CpuMfg::Nvidia => write!(f, "nvidia"), + CpuMfg::Arm => write!(f, "arm"), + } + } +} + +impl std::str::FromStr for CpuMfg { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "intel" => Ok(CpuMfg::Intel), + "amd" => Ok(CpuMfg::Amd), + "apple" => Ok(CpuMfg::Apple), + "nvidia" => Ok(CpuMfg::Nvidia), + "arm" => Ok(CpuMfg::Arm), + "unknown" => Ok(CpuMfg::Unknown), + _ => Err(()), + } + } +} + +/// CPU architecture +#[derive(Clone, Debug, sqlx::Type, PartialEq, Eq, Default, Copy)] +#[repr(u16)] +pub enum CpuArch { + #[default] + Unknown = 0, + X86_64 = 1, + ARM64 = 2, +} + +impl Display for CpuArch { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CpuArch::Unknown => write!(f, "unknown"), + CpuArch::X86_64 => write!(f, "x86_64"), + CpuArch::ARM64 => write!(f, "arm64"), + } + } +} + +impl std::str::FromStr for CpuArch { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "x86_64" | "x86-64" | "amd64" => Ok(CpuArch::X86_64), + "arm64" | "aarch64" => Ok(CpuArch::ARM64), + "unknown" => Ok(CpuArch::Unknown), + _ => Err(()), + } + } +} + +/// CPU feature flags relevant for workload capability +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum CpuFeature { + // ── SIMD instruction sets (x86) ────────────────────────────────────────── + SSE, + SSE2, + SSE3, + SSSE3, + SSE4_1, + SSE4_2, + AVX, + AVX2, + /// Fused Multiply-Add (FMA3) + FMA, + /// Half-precision floating point conversion + F16C, + /// AVX-512 Foundation + AVX512F, + /// AVX-512 Vector Neural Network Instructions (ML inference) + AVX512VNNI, + /// AVX-512 BFloat16 (ML training/inference) + AVX512BF16, + /// AVX-VNNI (VEX-encoded, non-AVX-512) - Alder Lake+ + AVXVNNI, + + // ── SIMD instruction sets (ARM) ────────────────────────────────────────── + /// ARM NEON SIMD + NEON, + /// ARM Scalable Vector Extension + SVE, + /// ARM SVE2 + SVE2, + + // ── Cryptographic acceleration ─────────────────────────────────────────── + /// AES-NI (x86) / AES (ARM) + AES, + /// SHA extensions (SHA-1, SHA-256) + SHA, + /// SHA-512 extensions + SHA512, + /// Polynomial multiplication for GCM/CRC (x86 PCLMULQDQ / ARM PMULL) + PCLMULQDQ, + /// Hardware random number generation (RDRAND/RDSEED, ARM RNDR) + RNG, + /// Galois Field New Instructions (crypto, compression) + GFNI, + /// Vector AES (AVX-512 / AVX) + VAES, + /// Vector PCLMULQDQ (AVX-512 / AVX) + VPCLMULQDQ, + + // ── Virtualization (CPU features) ──────────────────────────────────────── + /// Hardware virtualization (Intel VT-x / AMD-V / ARM VHE) + VMX, + /// Nested virtualization support + NestedVirt, + + // ── AI/ML acceleration (CPU integrated) ────────────────────────────────── + /// Intel AMX (Advanced Matrix Extensions) - Sapphire Rapids+ + AMX, + /// ARM SME (Scalable Matrix Extension) + SME, + + // ── Confidential computing (CPU features) ──────────────────────────────── + /// Intel SGX (Software Guard Extensions) + SGX, + /// AMD SEV (Secure Encrypted Virtualization) + SEV, + /// Intel TDX (Trust Domain Extensions) + TDX, + + // ── Hardware video encode (iGPU: Intel QSV, AMD VCN / dGPU: NVENC, AMF) ── + /// H.264/AVC hardware encode + EncodeH264, + /// H.265/HEVC hardware encode + EncodeHEVC, + /// AV1 hardware encode + EncodeAV1, + /// VP9 hardware encode + EncodeVP9, + /// JPEG hardware encode + EncodeJPEG, + + // ── Hardware video decode (iGPU: Intel QSV, AMD VCN / dGPU: NVDEC, AMF) ── + /// H.264/AVC hardware decode + DecodeH264, + /// H.265/HEVC hardware decode + DecodeHEVC, + /// AV1 hardware decode + DecodeAV1, + /// VP9 hardware decode + DecodeVP9, + /// JPEG hardware decode + DecodeJPEG, + /// MPEG-2 hardware decode + DecodeMPEG2, + /// VC-1 hardware decode + DecodeVC1, + + // ── Video processing ───────────────────────────────────────────────────── + /// Hardware video scaling/resize + VideoScaling, + /// Hardware deinterlacing + VideoDeinterlace, + /// Hardware color space conversion + VideoCSC, + /// Hardware video composition/overlay + VideoComposition, +} + +/// Discrete GPU manufacturer +#[derive(Clone, Debug, sqlx::Type, PartialEq, Eq, Default)] +#[repr(u16)] +pub enum GpuMfg { + #[default] + None, + Nvidia, + Amd, +} + +impl Display for GpuMfg { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + GpuMfg::None => write!(f, "none"), + GpuMfg::Nvidia => write!(f, "nvidia"), + GpuMfg::Amd => write!(f, "amd"), + } + } +} + +impl Display for CpuFeature { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let s = match self { + // SIMD (x86) + CpuFeature::SSE => "SSE", + CpuFeature::SSE2 => "SSE2", + CpuFeature::SSE3 => "SSE3", + CpuFeature::SSSE3 => "SSSE3", + CpuFeature::SSE4_1 => "SSE4_1", + CpuFeature::SSE4_2 => "SSE4_2", + CpuFeature::AVX => "AVX", + CpuFeature::AVX2 => "AVX2", + CpuFeature::FMA => "FMA", + CpuFeature::F16C => "F16C", + CpuFeature::AVX512F => "AVX512F", + CpuFeature::AVX512VNNI => "AVX512VNNI", + CpuFeature::AVX512BF16 => "AVX512BF16", + CpuFeature::AVXVNNI => "AVXVNNI", + // SIMD (ARM) + CpuFeature::NEON => "NEON", + CpuFeature::SVE => "SVE", + CpuFeature::SVE2 => "SVE2", + // Crypto + CpuFeature::AES => "AES", + CpuFeature::SHA => "SHA", + CpuFeature::SHA512 => "SHA512", + CpuFeature::PCLMULQDQ => "PCLMULQDQ", + CpuFeature::RNG => "RNG", + CpuFeature::GFNI => "GFNI", + CpuFeature::VAES => "VAES", + CpuFeature::VPCLMULQDQ => "VPCLMULQDQ", + // Virtualization + CpuFeature::VMX => "VMX", + CpuFeature::NestedVirt => "NestedVirt", + // AI/ML + CpuFeature::AMX => "AMX", + CpuFeature::SME => "SME", + // Confidential computing + CpuFeature::SGX => "SGX", + CpuFeature::SEV => "SEV", + CpuFeature::TDX => "TDX", + // iGPU video encode + CpuFeature::EncodeH264 => "EncodeH264", + CpuFeature::EncodeHEVC => "EncodeHEVC", + CpuFeature::EncodeAV1 => "EncodeAV1", + CpuFeature::EncodeVP9 => "EncodeVP9", + CpuFeature::EncodeJPEG => "EncodeJPEG", + // iGPU video decode + CpuFeature::DecodeH264 => "DecodeH264", + CpuFeature::DecodeHEVC => "DecodeHEVC", + CpuFeature::DecodeAV1 => "DecodeAV1", + CpuFeature::DecodeVP9 => "DecodeVP9", + CpuFeature::DecodeJPEG => "DecodeJPEG", + CpuFeature::DecodeMPEG2 => "DecodeMPEG2", + CpuFeature::DecodeVC1 => "DecodeVC1", + // Video processing + CpuFeature::VideoScaling => "VideoScaling", + CpuFeature::VideoDeinterlace => "VideoDeinterlace", + CpuFeature::VideoCSC => "VideoCSC", + CpuFeature::VideoComposition => "VideoComposition", + }; + f.write_str(s) + } +} + +impl FromStr for CpuFeature { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s { + // SIMD (x86) + "SSE" => Ok(CpuFeature::SSE), + "SSE2" => Ok(CpuFeature::SSE2), + "SSE3" => Ok(CpuFeature::SSE3), + "SSSE3" => Ok(CpuFeature::SSSE3), + "SSE4_1" => Ok(CpuFeature::SSE4_1), + "SSE4_2" => Ok(CpuFeature::SSE4_2), + "AVX" => Ok(CpuFeature::AVX), + "AVX2" => Ok(CpuFeature::AVX2), + "FMA" => Ok(CpuFeature::FMA), + "F16C" => Ok(CpuFeature::F16C), + "AVX512F" => Ok(CpuFeature::AVX512F), + "AVX512VNNI" => Ok(CpuFeature::AVX512VNNI), + "AVX512BF16" => Ok(CpuFeature::AVX512BF16), + "AVXVNNI" => Ok(CpuFeature::AVXVNNI), + // SIMD (ARM) + "NEON" => Ok(CpuFeature::NEON), + "SVE" => Ok(CpuFeature::SVE), + "SVE2" => Ok(CpuFeature::SVE2), + // Crypto + "AES" => Ok(CpuFeature::AES), + "SHA" => Ok(CpuFeature::SHA), + "SHA512" => Ok(CpuFeature::SHA512), + "PCLMULQDQ" => Ok(CpuFeature::PCLMULQDQ), + "RNG" => Ok(CpuFeature::RNG), + "GFNI" => Ok(CpuFeature::GFNI), + "VAES" => Ok(CpuFeature::VAES), + "VPCLMULQDQ" => Ok(CpuFeature::VPCLMULQDQ), + // Virtualization + "VMX" => Ok(CpuFeature::VMX), + "NestedVirt" => Ok(CpuFeature::NestedVirt), + // AI/ML + "AMX" => Ok(CpuFeature::AMX), + "SME" => Ok(CpuFeature::SME), + // Confidential computing + "SGX" => Ok(CpuFeature::SGX), + "SEV" => Ok(CpuFeature::SEV), + "TDX" => Ok(CpuFeature::TDX), + // iGPU video encode + "EncodeH264" => Ok(CpuFeature::EncodeH264), + "EncodeHEVC" => Ok(CpuFeature::EncodeHEVC), + "EncodeAV1" => Ok(CpuFeature::EncodeAV1), + "EncodeVP9" => Ok(CpuFeature::EncodeVP9), + "EncodeJPEG" => Ok(CpuFeature::EncodeJPEG), + // iGPU video decode + "DecodeH264" => Ok(CpuFeature::DecodeH264), + "DecodeHEVC" => Ok(CpuFeature::DecodeHEVC), + "DecodeAV1" => Ok(CpuFeature::DecodeAV1), + "DecodeVP9" => Ok(CpuFeature::DecodeVP9), + "DecodeJPEG" => Ok(CpuFeature::DecodeJPEG), + "DecodeMPEG2" => Ok(CpuFeature::DecodeMPEG2), + "DecodeVC1" => Ok(CpuFeature::DecodeVC1), + // Video processing + "VideoScaling" => Ok(CpuFeature::VideoScaling), + "VideoDeinterlace" => Ok(CpuFeature::VideoDeinterlace), + "VideoCSC" => Ok(CpuFeature::VideoCSC), + "VideoComposition" => Ok(CpuFeature::VideoComposition), + other => Err(format!("unknown CpuFeature: {}", other)), + } + } +} + #[derive(FromRow, Clone, Debug)] pub struct VmHostRegion { pub id: u64, @@ -102,6 +440,9 @@ pub struct VmHost { pub ip: String, /// Total number of CPU cores pub cpu: u16, + pub cpu_mfg: CpuMfg, + pub cpu_arch: CpuArch, + pub cpu_features: CommaSeparated, /// Total memory size in bytes pub memory: u64, /// If VM's should be provisioned on this host @@ -116,6 +457,10 @@ pub struct VmHost { pub load_disk: f32, /// VLAN id assigned to all vms on the host pub vlan_id: Option, + /// SSH username for running host utilities (default: root) + pub ssh_user: Option, + /// SSH private key for running host utilities (encrypted, PEM format) + pub ssh_key: Option, } #[derive(FromRow, Clone, Debug, Default)] @@ -392,6 +737,9 @@ pub struct VmTemplate { pub created: DateTime, pub expires: Option>, pub cpu: u16, + pub cpu_mfg: CpuMfg, + pub cpu_arch: CpuArch, + pub cpu_features: CommaSeparated, pub memory: u64, pub disk_size: u64, pub disk_type: DiskType, @@ -411,6 +759,9 @@ pub struct VmCustomTemplate { pub disk_type: DiskType, pub disk_interface: DiskInterface, pub pricing_id: u64, + pub cpu_mfg: CpuMfg, + pub cpu_arch: CpuArch, + pub cpu_features: CommaSeparated, } /// Custom pricing template, usually 1 per region @@ -423,6 +774,9 @@ pub struct VmCustomPricing { pub expires: Option>, pub region_id: u64, pub currency: String, + pub cpu_mfg: CpuMfg, + pub cpu_arch: CpuArch, + pub cpu_features: CommaSeparated, /// Cost per CPU core in smallest currency units (cents for fiat, millisats for BTC) pub cpu_cost: u64, /// Cost per GB ram in smallest currency units (cents for fiat, millisats for BTC) @@ -1198,6 +1552,58 @@ mod tests { assert_eq!(InternetRegistry::LACNIC.min_ipv6_prefix_size(), 48); assert_eq!(InternetRegistry::AFRINIC.min_ipv6_prefix_size(), 48); } + + #[test] + fn test_cpu_mfg_from_str() { + assert_eq!("intel".parse::().unwrap(), CpuMfg::Intel); + assert_eq!("Intel".parse::().unwrap(), CpuMfg::Intel); + assert_eq!("INTEL".parse::().unwrap(), CpuMfg::Intel); + assert_eq!("amd".parse::().unwrap(), CpuMfg::Amd); + assert_eq!("AMD".parse::().unwrap(), CpuMfg::Amd); + assert_eq!("apple".parse::().unwrap(), CpuMfg::Apple); + assert_eq!("nvidia".parse::().unwrap(), CpuMfg::Nvidia); + assert_eq!("unknown".parse::().unwrap(), CpuMfg::Unknown); + assert!("invalid".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn test_cpu_arch_from_str() { + assert_eq!("x86_64".parse::().unwrap(), CpuArch::X86_64); + assert_eq!("X86_64".parse::().unwrap(), CpuArch::X86_64); + assert_eq!("x86-64".parse::().unwrap(), CpuArch::X86_64); + assert_eq!("amd64".parse::().unwrap(), CpuArch::X86_64); + assert_eq!("arm64".parse::().unwrap(), CpuArch::ARM64); + assert_eq!("ARM64".parse::().unwrap(), CpuArch::ARM64); + assert_eq!("aarch64".parse::().unwrap(), CpuArch::ARM64); + assert_eq!("unknown".parse::().unwrap(), CpuArch::Unknown); + assert!("invalid".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn test_cpu_mfg_display_roundtrip() { + for mfg in [ + CpuMfg::Unknown, + CpuMfg::Intel, + CpuMfg::Amd, + CpuMfg::Apple, + CpuMfg::Nvidia, + ] { + let s = mfg.to_string(); + let parsed: CpuMfg = s.parse().unwrap(); + assert_eq!(parsed, mfg); + } + } + + #[test] + fn test_cpu_arch_display_roundtrip() { + for arch in [CpuArch::Unknown, CpuArch::X86_64, CpuArch::ARM64] { + let s = arch.to_string(); + let parsed: CpuArch = s.parse().unwrap(); + assert_eq!(parsed, arch); + } + } } /// Available IP Space - Inventory of IP ranges available for sale diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index ccdf2167..4d8b36be 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -10,7 +10,7 @@ use crate::{ use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost}; #[cfg(feature = "nostr-domain")] use crate::{LNVPSNostrDb, NostrDomain, NostrDomainHandle}; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use async_trait::async_trait; use sqlx::{Executor, MySqlPool, QueryBuilder, Row}; @@ -228,6 +228,9 @@ impl LNVpsDbBase for LNVpsDbMysql { name: row.get("name"), ip: row.get("ip"), cpu: row.get("cpu"), + cpu_mfg: row.get("cpu_mfg"), + cpu_arch: row.get("cpu_arch"), + cpu_features: row.get("cpu_features"), memory: row.get("memory"), enabled: row.get("enabled"), api_token: row.get("api_token"), @@ -235,6 +238,8 @@ impl LNVpsDbBase for LNVpsDbMysql { load_memory: row.get("load_memory"), load_disk: row.get("load_disk"), vlan_id: row.get("vlan_id"), + ssh_user: row.get("ssh_user"), + ssh_key: row.get("ssh_key"), }; let region = VmHostRegion { @@ -258,41 +263,60 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn update_host(&self, host: &VmHost) -> DbResult<()> { - sqlx::query("update vm_host set kind = ?, region_id = ?, name = ?, ip = ?, cpu = ?, memory = ?, enabled = ?, api_token = ?, load_cpu = ?, load_memory = ?, load_disk = ?, vlan_id = ? where id = ?") - .bind(&host.kind) - .bind(host.region_id) - .bind(&host.name) - .bind(&host.ip) - .bind(host.cpu) - .bind(host.memory) - .bind(host.enabled) - .bind(&host.api_token) - .bind(host.load_cpu) - .bind(host.load_memory) - .bind(host.load_disk) - .bind(host.vlan_id) - .bind(host.id) - .execute(&self.db) - .await?; + sqlx::query( + "UPDATE vm_host SET kind = ?, region_id = ?, name = ?, ip = ?, cpu = ?, \ + cpu_mfg = ?, cpu_arch = ?, cpu_features = ?, memory = ?, enabled = ?, \ + api_token = ?, load_cpu = ?, load_memory = ?, load_disk = ?, vlan_id = ?, \ + ssh_user = ?, ssh_key = ? WHERE id = ?", + ) + .bind(&host.kind) + .bind(host.region_id) + .bind(&host.name) + .bind(&host.ip) + .bind(host.cpu) + .bind(&host.cpu_mfg) + .bind(&host.cpu_arch) + .bind(&host.cpu_features) + .bind(host.memory) + .bind(host.enabled) + .bind(&host.api_token) + .bind(host.load_cpu) + .bind(host.load_memory) + .bind(host.load_disk) + .bind(host.vlan_id) + .bind(&host.ssh_user) + .bind(&host.ssh_key) + .bind(host.id) + .execute(&self.db) + .await?; Ok(()) } async fn create_host(&self, host: &VmHost) -> DbResult { - let result = sqlx::query("insert into vm_host (kind, region_id, name, ip, cpu, memory, enabled, api_token, load_cpu, load_memory, load_disk, vlan_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") - .bind(&host.kind) - .bind(host.region_id) - .bind(&host.name) - .bind(&host.ip) - .bind(host.cpu) - .bind(host.memory) - .bind(host.enabled) - .bind(&host.api_token) - .bind(host.load_cpu) - .bind(host.load_memory) - .bind(host.load_disk) - .bind(host.vlan_id) - .execute(&self.db) - .await?; + let result = sqlx::query( + "INSERT INTO vm_host (kind, region_id, name, ip, cpu, cpu_mfg, cpu_arch, \ + cpu_features, memory, enabled, api_token, load_cpu, load_memory, load_disk, \ + vlan_id, ssh_user, ssh_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&host.kind) + .bind(host.region_id) + .bind(&host.name) + .bind(&host.ip) + .bind(host.cpu) + .bind(&host.cpu_mfg) + .bind(&host.cpu_arch) + .bind(&host.cpu_features) + .bind(host.memory) + .bind(host.enabled) + .bind(&host.api_token) + .bind(host.load_cpu) + .bind(host.load_memory) + .bind(host.load_disk) + .bind(host.vlan_id) + .bind(&host.ssh_user) + .bind(&host.ssh_key) + .execute(&self.db) + .await?; Ok(result.last_insert_id()) } @@ -440,12 +464,15 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn insert_vm_template(&self, template: &VmTemplate) -> DbResult { - Ok(sqlx::query("insert into vm_template(name,enabled,created,expires,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) values(?,?,?,?,?,?,?,?,?,?,?) returning id") + Ok(sqlx::query("insert into vm_template(name,enabled,created,expires,cpu,cpu_mfg,cpu_arch,cpu_features,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?) returning id") .bind(&template.name) .bind(template.enabled) .bind(template.created) .bind(template.expires) .bind(template.cpu) + .bind(&template.cpu_mfg) + .bind(&template.cpu_arch) + .bind(&template.cpu_features) .bind(template.memory) .bind(template.disk_size) .bind(template.disk_type) @@ -773,26 +800,32 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn insert_custom_vm_template(&self, template: &VmCustomTemplate) -> DbResult { - Ok(sqlx::query("insert into vm_custom_template(cpu,memory,disk_size,disk_type,disk_interface,pricing_id) values(?,?,?,?,?,?) returning id") + Ok(sqlx::query("insert into vm_custom_template(cpu,memory,disk_size,disk_type,disk_interface,pricing_id,cpu_mfg,cpu_arch,cpu_features) values(?,?,?,?,?,?,?,?,?) returning id") .bind(template.cpu) .bind(template.memory) .bind(template.disk_size) .bind(template.disk_type) .bind(template.disk_interface) .bind(template.pricing_id) + .bind(&template.cpu_mfg) + .bind(&template.cpu_arch) + .bind(&template.cpu_features) .fetch_one(&self.db) .await? .try_get(0)?) } async fn update_custom_vm_template(&self, template: &VmCustomTemplate) -> DbResult<()> { - sqlx::query("update vm_custom_template set cpu=?, memory=?, disk_size=?, disk_type=?, disk_interface=?, pricing_id=? where id=?") + sqlx::query("update vm_custom_template set cpu=?, memory=?, disk_size=?, disk_type=?, disk_interface=?, pricing_id=?, cpu_mfg=?, cpu_arch=?, cpu_features=? where id=?") .bind(template.cpu) .bind(template.memory) .bind(template.disk_size) .bind(template.disk_type) .bind(template.disk_interface) .bind(template.pricing_id) + .bind(&template.cpu_mfg) + .bind(&template.cpu_arch) + .bind(&template.cpu_features) .bind(template.id) .execute(&self.db) .await?; @@ -1648,9 +1681,11 @@ impl LNVpsDbBase for LNVpsDbMysql { // ======================================================================== async fn list_payment_method_configs(&self) -> DbResult> { - Ok(sqlx::query_as("SELECT * FROM payment_method_config ORDER BY company_id, payment_method, name") - .fetch_all(&self.db) - .await?) + Ok(sqlx::query_as( + "SELECT * FROM payment_method_config ORDER BY company_id, payment_method, name", + ) + .fetch_all(&self.db) + .await?) } async fn list_payment_method_configs_for_company( @@ -1678,10 +1713,12 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn get_payment_method_config(&self, id: u64) -> DbResult { - Ok(sqlx::query_as("SELECT * FROM payment_method_config WHERE id = ?") - .bind(id) - .fetch_one(&self.db) - .await?) + Ok( + sqlx::query_as("SELECT * FROM payment_method_config WHERE id = ?") + .bind(id) + .fetch_one(&self.db) + .await?, + ) } async fn get_payment_method_config_for_company( @@ -2544,7 +2581,7 @@ impl AdminDb for LNVpsDbMysql { async fn update_vm_template(&self, template: &VmTemplate) -> DbResult<()> { sqlx::query( r#"UPDATE vm_template SET - name = ?, enabled = ?, expires = ?, cpu = ?, memory = ?, + name = ?, enabled = ?, expires = ?, cpu = ?, cpu_mfg = ?, cpu_arch = ?, cpu_features = ?, memory = ?, disk_size = ?, disk_type = ?, disk_interface = ?, cost_plan_id = ?, region_id = ? WHERE id = ?"#, @@ -2553,6 +2590,9 @@ impl AdminDb for LNVpsDbMysql { .bind(template.enabled) .bind(template.expires) .bind(template.cpu) + .bind(&template.cpu_mfg) + .bind(&template.cpu_arch) + .bind(&template.cpu_features) .bind(template.memory) .bind(template.disk_size) .bind(template.disk_type) @@ -2633,8 +2673,8 @@ impl AdminDb for LNVpsDbMysql { async fn insert_custom_pricing(&self, pricing: &VmCustomPricing) -> DbResult { let query = r#" - INSERT INTO vm_custom_pricing (name, enabled, created, expires, region_id, currency, cpu_cost, memory_cost, ip4_cost, ip6_cost, min_cpu, max_cpu, min_memory, max_memory) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO vm_custom_pricing (name, enabled, created, expires, region_id, currency, cpu_mfg, cpu_arch, cpu_features, cpu_cost, memory_cost, ip4_cost, ip6_cost, min_cpu, max_cpu, min_memory, max_memory) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#; let result = sqlx::query(query) @@ -2644,6 +2684,9 @@ impl AdminDb for LNVpsDbMysql { .bind(pricing.expires) .bind(pricing.region_id) .bind(&pricing.currency) + .bind(&pricing.cpu_mfg) + .bind(&pricing.cpu_arch) + .bind(&pricing.cpu_features) .bind(pricing.cpu_cost) .bind(pricing.memory_cost) .bind(pricing.ip4_cost) @@ -2662,7 +2705,7 @@ impl AdminDb for LNVpsDbMysql { let query = r#" UPDATE vm_custom_pricing SET name = ?, enabled = ?, expires = ?, region_id = ?, currency = ?, - cpu_cost = ?, memory_cost = ?, ip4_cost = ?, ip6_cost = ?, + cpu_mfg = ?, cpu_arch = ?, cpu_features = ?, cpu_cost = ?, memory_cost = ?, ip4_cost = ?, ip6_cost = ?, min_cpu = ?, max_cpu = ?, min_memory = ?, max_memory = ? WHERE id = ? "#; @@ -2673,6 +2716,9 @@ impl AdminDb for LNVpsDbMysql { .bind(pricing.expires) .bind(pricing.region_id) .bind(&pricing.currency) + .bind(&pricing.cpu_mfg) + .bind(&pricing.cpu_arch) + .bind(&pricing.cpu_features) .bind(pricing.cpu_cost) .bind(pricing.memory_cost) .bind(pricing.ip4_cost) diff --git a/lnvps_host_util/Cargo.toml b/lnvps_host_util/Cargo.toml new file mode 100644 index 00000000..4bfa6c44 --- /dev/null +++ b/lnvps_host_util/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "lnvps_host_util" +version = "0.1.0" +edition.workspace = true +description = "Host CPU detection utility for LNVPS" + +[[bin]] +name = "lnvps-host-info" +path = "src/main.rs" + +[features] +default = ["vaapi", "nvml"] +vaapi = ["dep:cros-libva"] +nvml = ["dep:nvml-wrapper"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +raw-cpuid = "11" +cros-libva = { version = "0.0", optional = true } +nvml-wrapper = { version = "0.10", optional = true } diff --git a/lnvps_host_util/Dockerfile b/lnvps_host_util/Dockerfile new file mode 100644 index 00000000..b3f1e15f --- /dev/null +++ b/lnvps_host_util/Dockerfile @@ -0,0 +1,12 @@ +ARG IMAGE=rust:trixie + +FROM $IMAGE AS build +WORKDIR /app/src +COPY . . +RUN apt update && apt -y install libva-dev libclang-dev +RUN cargo install --locked --root /app/build --path lnvps_host_util lnvps_host_util + +FROM debian:trixie-slim AS runner +WORKDIR /app +COPY --from=build /app/build/bin/lnvps-host-info . +ENTRYPOINT ["./lnvps-host-info"] diff --git a/lnvps_host_util/src/gpu.rs b/lnvps_host_util/src/gpu.rs new file mode 100644 index 00000000..0ef6854c --- /dev/null +++ b/lnvps_host_util/src/gpu.rs @@ -0,0 +1,236 @@ +//! GPU detection for NVIDIA (NVML) and AMD (sysfs) + +use crate::{DetectionResult, GpuMfg}; +use std::path::Path; + +/// Combined GPU detection result +pub struct GpuInfo { + pub mfg: GpuMfg, + pub model: Option, + pub features: Vec, + /// NVIDIA NVML detection result + pub nvml: DetectionResult, + /// AMD GPU detection result + pub amd: DetectionResult, +} + +/// Detect NVIDIA GPUs with NVENC/NVDEC support using NVML +pub fn detect_nvidia() -> DetectionResult { + use nvml_wrapper::enum_wrappers::device::EncoderType; + use nvml_wrapper::Nvml; + + let mut features = Vec::new(); + + // Initialize NVML + let nvml = match Nvml::init() { + Ok(n) => n, + Err(e) => return DetectionResult::error(format!("NVML init failed: {e}")), + }; + + // Get device count + let device_count = match nvml.device_count() { + Ok(c) => c, + Err(e) => return DetectionResult::error(format!("failed to get device count: {e}")), + }; + + if device_count == 0 { + return DetectionResult::NotFound; + } + + // Check capabilities of the first GPU (primary) + let device = match nvml.device_by_index(0) { + Ok(d) => d, + Err(e) => { + // Have NVIDIA GPU but can't query details + return DetectionResult::error(format!("failed to query device 0: {e}")); + } + }; + + // Get GPU name + let model = device.name().ok(); + + // Get GPU architecture via compute capability + let (major, minor) = match device.cuda_compute_capability() { + Ok(cc) => (cc.major, cc.minor), + Err(_) => (0, 0), + }; + + // Check encoder capabilities directly from NVML + if let Ok(encoder_cap) = device.encoder_capacity(EncoderType::H264) { + if encoder_cap > 0 { + features.push("EncodeH264".to_string()); + } + } + if let Ok(encoder_cap) = device.encoder_capacity(EncoderType::HEVC) { + if encoder_cap > 0 { + features.push("EncodeHEVC".to_string()); + } + } + + // Fallback to compute capability if encoder_capacity doesn't work + if !features.contains(&"EncodeH264".to_string()) && major >= 3 { + // Kepler (SM 3.0+) and newer support NVENC H.264 + features.push("EncodeH264".to_string()); + } + if !features.contains(&"EncodeHEVC".to_string()) && (major > 5 || (major == 5 && minor >= 2)) { + // Maxwell Gen 2 (SM 5.2+) and newer support HEVC + features.push("EncodeHEVC".to_string()); + } + + // Decoder capabilities based on compute capability + // Kepler+ supports H.264 decode + if major >= 3 { + features.push("DecodeH264".to_string()); + } + + // Maxwell Gen 2+ supports HEVC decode + if major > 5 || (major == 5 && minor >= 2) { + features.push("DecodeHEVC".to_string()); + } + + // Pascal (SM 6.x) and newer support VP9 decode + if major >= 6 { + features.push("DecodeVP9".to_string()); + } + + // Ampere (SM 8.6+) supports AV1 decode + // GA10x chips are SM 8.6 + if major > 8 || (major == 8 && minor >= 6) { + features.push("DecodeAV1".to_string()); + } + + // Ada Lovelace (SM 8.9) supports AV1 encode + // AD10x chips are SM 8.9 + if major > 8 || (major == 8 && minor >= 9) { + features.push("EncodeAV1".to_string()); + } + + match model { + Some(name) => DetectionResult::ok_with_name(name, features), + None => DetectionResult::ok(features), + } +} + +/// Detect AMD discrete GPUs with VCN/AMF support +#[cfg(target_os = "linux")] +pub fn detect_amd() -> DetectionResult { + let mut features = Vec::new(); + let mut model: Option = None; + + // Check for AMD GPU via sysfs + // Look for amdgpu driver in /sys/bus/pci/drivers/amdgpu/ + let amdgpu_driver_path = Path::new("/sys/bus/pci/drivers/amdgpu"); + if !amdgpu_driver_path.exists() { + return DetectionResult::NotFound; + } + + // Check if there are any bound devices + let entries = match std::fs::read_dir(amdgpu_driver_path) { + Ok(e) => e, + Err(e) => return DetectionResult::error(format!("failed to read amdgpu driver: {e}")), + }; + + let has_gpu = entries + .filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().starts_with("0000:")); + + if !has_gpu { + return DetectionResult::NotFound; + } + + // AMD GPUs with VCN (Video Core Next) support hardware encode/decode + // VCN is present in Vega and newer (RDNA, RDNA2, RDNA3) + // Most modern AMD GPUs support these via VCN/AMF + features.push("EncodeH264".to_string()); + features.push("EncodeHEVC".to_string()); + features.push("DecodeH264".to_string()); + features.push("DecodeHEVC".to_string()); + features.push("DecodeVP9".to_string()); + + // Check for VCN version to determine AV1 support + // VCN 3.0+ (RDNA2) supports AV1 decode, VCN 4.0+ (RDNA3) supports AV1 encode + + // Try to detect GPU generation from device files + if let Ok(drm_entries) = std::fs::read_dir("/sys/class/drm") { + for entry in drm_entries.filter_map(|e| e.ok()) { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.starts_with("card") || name_str.contains('-') { + continue; + } + + // Read the device marketing name or check for VCN version + let product_name_path = entry.path().join("device/product_name"); + if let Ok(product) = std::fs::read_to_string(&product_name_path) { + let product_trimmed = product.trim().to_string(); + let product_lower = product_trimmed.to_lowercase(); + + // Save the model name if we haven't found one yet + if model.is_none() && !product_trimmed.is_empty() { + model = Some(product_trimmed); + } + + // RDNA3 (RX 7000 series) supports AV1 encode + if product_lower.contains("7900") + || product_lower.contains("7800") + || product_lower.contains("7700") + || product_lower.contains("7600") + { + features.push("EncodeAV1".to_string()); + features.push("DecodeAV1".to_string()); + } + // RDNA2 (RX 6000 series) supports AV1 decode only + else if product_lower.contains("6900") + || product_lower.contains("6800") + || product_lower.contains("6700") + || product_lower.contains("6600") + || product_lower.contains("6500") + || product_lower.contains("6400") + { + features.push("DecodeAV1".to_string()); + } + } + } + } + + match model { + Some(name) => DetectionResult::ok_with_name(name, features), + None => DetectionResult::ok(features), + } +} + +#[cfg(not(target_os = "linux"))] +pub fn detect_amd() -> DetectionResult { + DetectionResult::Unsupported +} + +/// Detect all discrete GPUs and return combined info +pub fn detect_gpu() -> GpuInfo { + let nvml = detect_nvidia(); + let amd = detect_amd(); + + // Determine primary GPU (prefer NVIDIA if both present) + let (mfg, model, features) = if nvml.is_ok() { + ( + GpuMfg::Nvidia, + nvml.name().map(String::from), + nvml.features().to_vec(), + ) + } else if amd.is_ok() { + ( + GpuMfg::Amd, + amd.name().map(String::from), + amd.features().to_vec(), + ) + } else { + (GpuMfg::None, None, Vec::new()) + }; + + GpuInfo { + mfg, + model, + features, + nvml, + amd, + } +} diff --git a/lnvps_host_util/src/main.rs b/lnvps_host_util/src/main.rs new file mode 100644 index 00000000..5ce6c4c6 --- /dev/null +++ b/lnvps_host_util/src/main.rs @@ -0,0 +1,710 @@ +//! Host CPU detection utility for LNVPS +//! +//! Outputs JSON with cpu_mfg, cpu_arch, and cpu_features that can be used +//! when registering a host with LNVPS. + +mod gpu; + +#[cfg(target_os = "linux")] +use cros_libva::Display; +#[cfg(target_arch = "x86_64")] +use raw_cpuid::CpuId; +use serde::Serialize; +use std::path::Path; + +/// CPU manufacturer +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] // Variants used conditionally per architecture +pub enum CpuMfg { + Unknown, + Intel, + Amd, + Apple, + Nvidia, + Arm, +} + +/// CPU architecture +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] // Variants used conditionally per architecture +pub enum CpuArch { + Unknown, + X86_64, + Arm64, +} + +/// Discrete GPU manufacturer +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] // Variants used conditionally per platform +pub enum GpuMfg { + None, + Nvidia, + Amd, +} + +/// Generic detection result for hardware feature detection +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum DetectionResult { + /// Detection succeeded with features found + Ok { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + features: Vec, + }, + /// Hardware not present or driver not loaded + NotFound, + /// Hardware present but initialization/query failed + #[serde(rename = "error")] + Error { reason: String }, + /// Not supported on this platform + Unsupported, +} + +impl DetectionResult { + /// Create a successful result with features + pub fn ok(features: Vec) -> Self { + Self::Ok { + name: None, + features, + } + } + + /// Create a successful result with name and features + pub fn ok_with_name(name: impl Into, features: Vec) -> Self { + Self::Ok { + name: Some(name.into()), + features, + } + } + + /// Create an error result + pub fn error(reason: impl Into) -> Self { + Self::Error { + reason: reason.into(), + } + } + + /// Get features if detection was successful + pub fn features(&self) -> &[String] { + match self { + Self::Ok { features, .. } => features, + _ => &[], + } + } + + /// Get name if detection was successful + pub fn name(&self) -> Option<&str> { + match self { + Self::Ok { name, .. } => name.as_deref(), + _ => None, + } + } + + /// Check if detection was successful + pub fn is_ok(&self) -> bool { + matches!(self, Self::Ok { .. }) + } +} + +/// Output structure matching LNVPS host registration format +#[derive(Debug, Serialize)] +struct HostInfo { + cpu_mfg: CpuMfg, + cpu_arch: CpuArch, + cpu_features: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + cpu_model: Option, + gpu_mfg: GpuMfg, + #[serde(skip_serializing_if = "Option::is_none")] + gpu_model: Option, + gpu_features: Vec, + /// VA-API (iGPU) detection result + vaapi: DetectionResult, + /// NVIDIA NVML detection result + nvml: DetectionResult, + /// AMD GPU detection result + amd: DetectionResult, +} + +#[cfg(target_arch = "x86_64")] +fn detect_cpu_mfg() -> CpuMfg { + let cpuid = CpuId::new(); + + if let Some(vendor) = cpuid.get_vendor_info() { + match vendor.as_str() { + "GenuineIntel" => CpuMfg::Intel, + "AuthenticAMD" => CpuMfg::Amd, + _ => CpuMfg::Unknown, + } + } else { + CpuMfg::Unknown + } +} + +#[cfg(target_arch = "aarch64")] +fn detect_cpu_mfg() -> CpuMfg { + // On ARM, try to detect from /proc/cpuinfo + if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") { + // Check for Apple Silicon + if cpuinfo.contains("Apple") { + return CpuMfg::Apple; + } + // Check CPU implementer field + for line in cpuinfo.lines() { + if line.starts_with("CPU implementer") { + if let Some(value) = line.split(':').nth(1) { + match value.trim() { + "0x41" => return CpuMfg::Arm, // ARM Ltd + "0x4e" => return CpuMfg::Nvidia, // NVIDIA + _ => {} + } + } + } + } + } + CpuMfg::Unknown +} + +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +fn detect_cpu_mfg() -> CpuMfg { + CpuMfg::Unknown +} + +fn detect_cpu_arch() -> CpuArch { + #[cfg(target_arch = "x86_64")] + { + CpuArch::X86_64 + } + #[cfg(target_arch = "aarch64")] + { + CpuArch::Arm64 + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + CpuArch::Unknown + } +} + +#[cfg(target_arch = "x86_64")] +fn detect_cpu_model() -> Option { + let cpuid = CpuId::new(); + cpuid + .get_processor_brand_string() + .map(|b| b.as_str().trim().to_string()) +} + +#[cfg(target_arch = "aarch64")] +fn detect_cpu_model() -> Option { + // On ARM, try to get model from /proc/cpuinfo + if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") { + for line in cpuinfo.lines() { + if line.starts_with("Model") || line.starts_with("Hardware") { + if let Some(value) = line.split(':').nth(1) { + return Some(value.trim().to_string()); + } + } + } + } + None +} + +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +fn detect_cpu_model() -> Option { + None +} + +/// Detect x86_64 CPU features +#[cfg(target_arch = "x86_64")] +fn detect_cpu_features() -> Vec { + let cpuid = CpuId::new(); + let mut features = Vec::new(); + + // Check feature flags from CPUID + if let Some(fi) = cpuid.get_feature_info() { + // SSE family + if fi.has_sse() { + features.push("SSE".to_string()); + } + if fi.has_sse2() { + features.push("SSE2".to_string()); + } + if fi.has_sse3() { + features.push("SSE3".to_string()); + } + if fi.has_ssse3() { + features.push("SSSE3".to_string()); + } + if fi.has_sse41() { + features.push("SSE4_1".to_string()); + } + if fi.has_sse42() { + features.push("SSE4_2".to_string()); + } + + // AVX (basic) + if fi.has_avx() { + features.push("AVX".to_string()); + } + + // FMA + if fi.has_fma() { + features.push("FMA".to_string()); + } + + // F16C + if fi.has_f16c() { + features.push("F16C".to_string()); + } + + // Crypto + if fi.has_aesni() { + features.push("AES".to_string()); + } + if fi.has_pclmulqdq() { + features.push("PCLMULQDQ".to_string()); + } + + // Virtualization + if fi.has_vmx() { + features.push("VMX".to_string()); + } + } + + // Extended feature flags (leaf 7) + if let Some(ef) = cpuid.get_extended_feature_info() { + // AVX2 + if ef.has_avx2() { + features.push("AVX2".to_string()); + } + + // AVX-512 family + if ef.has_avx512f() { + features.push("AVX512F".to_string()); + } + if ef.has_avx512vnni() { + features.push("AVX512VNNI".to_string()); + } + if ef.has_avx512_bf16() { + features.push("AVX512BF16".to_string()); + } + + // AVX-VNNI (VEX-encoded, non-AVX-512) + if ef.has_avx_vnni() { + features.push("AVXVNNI".to_string()); + } + + // SHA (SHA-1, SHA-256) + if ef.has_sha() { + features.push("SHA".to_string()); + } + + // GFNI, VAES, VPCLMULQDQ + if ef.has_gfni() { + features.push("GFNI".to_string()); + } + if ef.has_vaes() { + features.push("VAES".to_string()); + } + if ef.has_vpclmulqdq() { + features.push("VPCLMULQDQ".to_string()); + } + + // RNG (RDSEED) + if ef.has_rdseed() { + features.push("RNG".to_string()); + } + + // AMX (Advanced Matrix Extensions) + if ef.has_amx_tile() { + features.push("AMX".to_string()); + } + + // SGX + if ef.has_sgx() { + features.push("SGX".to_string()); + } + } + + // SHA512 extensions (CPUID leaf 7, subleaf 1, EAX bit 0) + // This is newer than what raw-cpuid exposes + if has_sha512_extensions() { + features.push("SHA512".to_string()); + } + + // AMD SEV (Secure Encrypted Virtualization) + if is_sev_enabled() { + features.push("SEV".to_string()); + } + + // Intel TDX (Trust Domain Extensions) + if is_tdx_enabled() { + features.push("TDX".to_string()); + } + + // Check for nested virtualization support + if is_nested_virt_enabled() { + features.push("NestedVirt".to_string()); + } + + // Sort for consistent output + features.sort(); + features +} + +/// Detect ARM64 CPU features +#[cfg(target_arch = "aarch64")] +fn detect_cpu_features() -> Vec { + let mut features = Vec::new(); + + // On ARM, we need to read from /proc/cpuinfo or use system registers + if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") { + let has_feature = |name: &str| { + cpuinfo.lines().any(|line| { + line.starts_with("Features") + && line + .split(':') + .nth(1) + .is_some_and(|f| f.split_whitespace().any(|feat| feat == name)) + }) + }; + + // SIMD + if has_feature("asimd") { + features.push("NEON".to_string()); + } + if has_feature("sve") { + features.push("SVE".to_string()); + } + if has_feature("sve2") { + features.push("SVE2".to_string()); + } + + // Crypto + if has_feature("aes") { + features.push("AES".to_string()); + } + if has_feature("sha1") || has_feature("sha2") { + features.push("SHA".to_string()); + } + if has_feature("sha512") || has_feature("sha3") { + features.push("SHA512".to_string()); + } + if has_feature("pmull") { + features.push("PCLMULQDQ".to_string()); + } + + // RNG + if has_feature("rng") { + features.push("RNG".to_string()); + } + + // SME (Scalable Matrix Extension) + if has_feature("sme") { + features.push("SME".to_string()); + } + } + + // Check for KVM/virtualization support + if std::path::Path::new("/dev/kvm").exists() { + features.push("NestedVirt".to_string()); + } + + features.sort(); + features +} + +/// Fallback for unsupported architectures +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +fn detect_cpu_features() -> Vec { + Vec::new() +} + +/// Check for SHA512 extensions via CPUID (leaf 7, subleaf 1, EAX bit 0) +#[cfg(target_arch = "x86_64")] +fn has_sha512_extensions() -> bool { + // raw-cpuid doesn't expose subleaf 1 directly, so we use inline asm + // CPUID leaf 7, subleaf 1, EAX bit 0 = SHA512 + let eax: u32; + unsafe { + std::arch::asm!( + "push rbx", // Save rbx (used by LLVM) + "cpuid", + "pop rbx", // Restore rbx + inout("eax") 7u32 => eax, + inout("ecx") 1u32 => _, + out("edx") _, + options(nostack), + ); + } + // Bit 0 of EAX = SHA512 + (eax & 1) != 0 +} + +#[cfg(not(target_arch = "x86_64"))] +fn has_sha512_extensions() -> bool { + false +} + +/// Check if AMD SEV is enabled (Linux only) +#[cfg(target_os = "linux")] +fn is_sev_enabled() -> bool { + // Check if SEV is enabled in KVM + if let Ok(content) = std::fs::read_to_string("/sys/module/kvm_amd/parameters/sev") { + if content.trim() == "Y" || content.trim() == "1" { + return true; + } + } + // Also check for /dev/sev device + std::path::Path::new("/dev/sev").exists() +} + +#[cfg(not(target_os = "linux"))] +fn is_sev_enabled() -> bool { + false +} + +/// Check if Intel TDX is enabled (Linux only) +#[cfg(target_os = "linux")] +fn is_tdx_enabled() -> bool { + // Check for TDX module loaded + std::path::Path::new("/sys/module/kvm_intel/parameters/tdx").exists() + || std::path::Path::new("/dev/tdx_guest").exists() + || std::path::Path::new("/dev/tdx-guest").exists() +} + +#[cfg(not(target_os = "linux"))] +fn is_tdx_enabled() -> bool { + false +} + +/// Check if nested virtualization is enabled (Linux only) +#[cfg(target_os = "linux")] +fn is_nested_virt_enabled() -> bool { + // Intel + if let Ok(content) = std::fs::read_to_string("/sys/module/kvm_intel/parameters/nested") { + if content.trim() == "Y" || content.trim() == "1" { + return true; + } + } + // AMD + if let Ok(content) = std::fs::read_to_string("/sys/module/kvm_amd/parameters/nested") { + if content.trim() == "Y" || content.trim() == "1" { + return true; + } + } + false +} + +#[cfg(not(target_os = "linux"))] +fn is_nested_virt_enabled() -> bool { + false +} + +/// Detect VA-API (iGPU) video encode/decode features +#[cfg(target_os = "linux")] +fn detect_vaapi() -> DetectionResult { + let mut features = Vec::new(); + + // Try to open DRM render nodes + let render_nodes = [ + "/dev/dri/renderD128", + "/dev/dri/renderD129", + "/dev/dri/renderD130", + ]; + + // Check if any render nodes exist + let any_render_node_exists = render_nodes.iter().any(|node| Path::new(node).exists()); + if !any_render_node_exists { + return DetectionResult::NotFound; + } + + let mut any_display_opened = false; + + for node in render_nodes { + if !Path::new(node).exists() { + continue; + } + + let display = match Display::open_drm_display(Path::new(node)) { + Ok(d) => d, + Err(_) => continue, + }; + + any_display_opened = true; + + // Query supported profiles and entrypoints + let profiles = match display.query_config_profiles() { + Ok(p) => p, + Err(_) => continue, + }; + + for profile in profiles { + let entrypoints = match display.query_config_entrypoints(profile) { + Ok(e) => e, + Err(_) => continue, + }; + + for entrypoint in entrypoints { + if let Some(feature) = map_vaapi_to_feature(profile, entrypoint as i32) { + if !features.contains(&feature) { + features.push(feature); + } + } + } + } + + // Check for video processing (VPP) support + if display + .query_config_entrypoints(cros_libva::VAProfile::VAProfileNone) + .is_ok_and(|eps| eps.contains(&cros_libva::VAEntrypoint::VAEntrypointVideoProc)) + { + for f in [ + "VideoScaling", + "VideoDeinterlace", + "VideoCSC", + "VideoComposition", + ] { + if !features.contains(&f.to_string()) { + features.push(f.to_string()); + } + } + } + + // Found a working display with features, no need to check other render nodes + if !features.is_empty() { + break; + } + } + + if !features.is_empty() { + DetectionResult::ok(features) + } else if any_display_opened { + DetectionResult::error("display opened but no encode/decode features found") + } else { + DetectionResult::error("failed to open any render node") + } +} + +#[cfg(not(target_os = "linux"))] +fn detect_vaapi() -> DetectionResult { + DetectionResult::Unsupported +} + +/// Map VA-API profile + entrypoint to CpuFeature name +fn map_vaapi_to_feature(profile: i32, entrypoint: i32) -> Option { + use cros_libva::VAEntrypoint::*; + use cros_libva::VAProfile::*; + + let is_encode = entrypoint == VAEntrypointEncSlice as i32 + || entrypoint == VAEntrypointEncSliceLP as i32 + || entrypoint == VAEntrypointEncPicture as i32; + let is_decode = entrypoint == VAEntrypointVLD as i32; + + // H.264/AVC + if profile == VAProfileH264Main + || profile == VAProfileH264High + || profile == VAProfileH264ConstrainedBaseline + { + if is_encode { + return Some("EncodeH264".to_string()); + } else if is_decode { + return Some("DecodeH264".to_string()); + } + } + + // H.265/HEVC + if profile == VAProfileHEVCMain || profile == VAProfileHEVCMain10 { + if is_encode { + return Some("EncodeHEVC".to_string()); + } else if is_decode { + return Some("DecodeHEVC".to_string()); + } + } + + // AV1 + if profile == VAProfileAV1Profile0 || profile == VAProfileAV1Profile1 { + if is_encode { + return Some("EncodeAV1".to_string()); + } else if is_decode { + return Some("DecodeAV1".to_string()); + } + } + + // VP9 + if profile == VAProfileVP9Profile0 + || profile == VAProfileVP9Profile1 + || profile == VAProfileVP9Profile2 + || profile == VAProfileVP9Profile3 + { + if is_encode { + return Some("EncodeVP9".to_string()); + } else if is_decode { + return Some("DecodeVP9".to_string()); + } + } + + // JPEG + if profile == VAProfileJPEGBaseline { + if is_encode { + return Some("EncodeJPEG".to_string()); + } else if is_decode { + return Some("DecodeJPEG".to_string()); + } + } + + // MPEG-2 + if profile == VAProfileMPEG2Simple || profile == VAProfileMPEG2Main { + if is_decode { + return Some("DecodeMPEG2".to_string()); + } + } + + // VC-1 + if profile == VAProfileVC1Simple + || profile == VAProfileVC1Main + || profile == VAProfileVC1Advanced + { + if is_decode { + return Some("DecodeVC1".to_string()); + } + } + + None +} + +fn main() { + // Detect CPU features + let mut cpu_features = detect_cpu_features(); + + // Detect VA-API (iGPU) features - these go into cpu_features + let vaapi = detect_vaapi(); + cpu_features.extend(vaapi.features().iter().cloned()); + + // Sort CPU features for consistent output + cpu_features.sort(); + cpu_features.dedup(); + + // Detect discrete GPU (NVIDIA NVENC/NVDEC, AMD VCN/AMF) + let mut gpu_info = gpu::detect_gpu(); + gpu_info.features.sort(); + gpu_info.features.dedup(); + + let info = HostInfo { + cpu_mfg: detect_cpu_mfg(), + cpu_arch: detect_cpu_arch(), + cpu_features, + cpu_model: detect_cpu_model(), + gpu_mfg: gpu_info.mfg, + gpu_model: gpu_info.model, + gpu_features: gpu_info.features, + vaapi, + nvml: gpu_info.nvml, + amd: gpu_info.amd, + }; + + println!("{}", serde_json::to_string_pretty(&info).unwrap()); +}