From bd1cdc8ea3b78319135cd5ba584d438cc422aea9 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 30 Jul 2026 14:16:49 +0100 Subject: [PATCH 1/4] Sell IP counts as a template/custom-pricing dimension --- ADMIN_API_ENDPOINTS.md | 34 ++++ API_CHANGELOG.md | 8 + lnvps_api/src/api/model.rs | 43 ++++ lnvps_api/src/host/mod.rs | 14 ++ lnvps_api/src/provisioner/vm.rs | 184 ++++++++++++++++-- lnvps_api_admin/src/admin/custom_pricing.rs | 24 +++ lnvps_api_admin/src/admin/model.rs | 24 +++ lnvps_api_admin/src/admin/vm_templates.rs | 10 + lnvps_api_admin/src/bin/generate_demo_data.rs | 4 + lnvps_api_common/src/capacity.rs | 108 ++++++++-- lnvps_api_common/src/mock.rs | 2 + lnvps_api_common/src/model.rs | 41 ++++ lnvps_api_common/src/network.rs | 147 ++++++++++++++ lnvps_api_common/src/pricing.rs | 134 +++++++++++-- .../20260730130000_template_ip_counts.sql | 51 +++++ lnvps_db/src/model.rs | 16 ++ lnvps_db/src/mysql.rs | 37 +++- lnvps_e2e/src/lifecycle.rs | 67 +++++++ 18 files changed, 893 insertions(+), 55 deletions(-) create mode 100644 lnvps_db/migrations/20260730130000_template_ip_counts.sql diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 1a6193c8..92bfac48 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -1764,6 +1764,12 @@ Body: "cost_plan_id": number, // optional - if not provided, cost plan will be auto-created "region_id": number, + "ip4_count": number, + // optional - IPv4 addresses included in this offer, default 1 + "ip6_count": number, + // optional - IPv6 addresses included in this offer, default 1. Assignment is + // best-effort: a region with no IPv6 range still provisions + // Cost plan auto-creation fields (used when cost_plan_id not provided) "cost_plan_name": "string", // optional, defaults to "{template_name} Cost Plan" @@ -1822,6 +1828,8 @@ Body (all optional): "disk_interface": "string", "cost_plan_id": number, "region_id": number, + "ip4_count": number, + "ip6_count": number, "cost_plan_name": "string", // Update associated cost plan name "cost_plan_amount": number, @@ -2026,6 +2034,14 @@ Body: // Minimum memory in bytes "max_memory": number, // Maximum memory in bytes + "min_ip4": number, + // optional - Minimum IPv4 addresses selectable, default 1 + "max_ip4": number, + // optional - Maximum IPv4 addresses selectable, default 1 + "min_ip6": number, + // optional - Minimum IPv6 addresses selectable, default 1 + "max_ip6": number, + // optional - Maximum IPv6 addresses selectable, default 1 "disk_iops_read": number, // optional - Maximum disk read IOPS (omit or null = uncapped) "disk_iops_write": number, @@ -2095,6 +2111,14 @@ Body (all optional): // Minimum memory in bytes "max_memory": number, // Maximum memory in bytes + "min_ip4": number, + // optional - Minimum IPv4 addresses selectable, default 1 + "max_ip4": number, + // optional - Maximum IPv4 addresses selectable, default 1 + "min_ip6": number, + // optional - Minimum IPv6 addresses selectable, default 1 + "max_ip6": number, + // optional - Maximum IPv6 addresses selectable, default 1 "disk_iops_read": "number | null", // Maximum disk read IOPS - send null to clear "disk_iops_write": "number | null", @@ -4759,6 +4783,8 @@ The RBAC system uses the following permission format: `resource::action` // DiskInterface enum: "sata", "scsi", or "pcie" "cost_plan_id": number, "region_id": number, + "ip4_count": number, + "ip6_count": number, "region_name": "string | null", // Populated with region name "cost_plan_name": "string | null", @@ -4816,6 +4842,14 @@ The RBAC system uses the following permission format: `resource::action` // Minimum memory in bytes "max_memory": number, // Maximum memory in bytes + "min_ip4": number, + // optional - Minimum IPv4 addresses selectable, default 1 + "max_ip4": number, + // optional - Maximum IPv4 addresses selectable, default 1 + "min_ip6": number, + // optional - Minimum IPv6 addresses selectable, default 1 + "max_ip6": number, + // optional - Maximum IPv6 addresses selectable, default 1 "disk_iops_read": number, // Maximum disk read IOPS - omitted when uncapped "disk_iops_write": number, diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 61b10966..cdf516e7 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -28,6 +28,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Admin can create a custom-spec VM** (issue #335) — `POST /api/admin/v1/vms/custom` creates a VM from an arbitrary spec for any user, billed against a custom pricing plan, instead of only from the fixed template catalog. Body is the customer custom-order shape plus `user_id`/`reason`: `pricing_id` (also decides the region), `cpu`, `memory` and `disk` in bytes, `disk_type`, `disk_interface`, optional `cpu_mfg`/`cpu_arch`/`cpu_feature`, `image_id`, `ssh_key_id` (must belong to the target user), optional `ref_code`. Returns `{ "job_id": string }`; the VM is created unpaid like a customer order. Unknown enum spellings are `400` and are never defaulted. Additive: `POST /api/admin/v1/vms` (template path) is unchanged. +- **IP counts are a sellable dimension of an offer** (issue #105) — a VM offer now states how many addresses it includes instead of implying exactly one IPv4 plus an optional IPv6. + - `ApiVmTemplate` (in `GET /api/v1/vm/templates`, `GET /api/v1/vm/{id}` and every VM listing) gains `ip4_count` and `ip6_count`. Additive; nothing existing changed shape. + - `GET /api/v1/vm/custom-template` params gain `min_ip4`/`max_ip4`/`min_ip6`/`max_ip6`. `max_ip4` is already capped by what the region can actually supply, so a client can use it directly as a selector bound, and a plan whose `min_ip4` cannot be met is not listed at all. + - `POST /api/v1/vm/custom-template` (and the price quote) accept `ip4_count`/`ip6_count`. Both default to `1` when omitted, so existing clients order exactly what they did before. Counts outside the plan's range are rejected. Cost is `count × ip4_cost` / `count × ip6_cost`, so the quote for a multi-address order is higher than before by exactly the extra addresses. + - Custom VM billing now takes the counts from the ordered spec rather than counting the VM's current assignments. Existing VMs are migrated to the counts they currently hold, so no live renewal amount moves. + - Provisioning allocates the stated number of addresses across the region's ranges. IPv4 is all-or-nothing: an order asking for addresses the region cannot supply fails instead of quietly getting fewer. IPv6 remains best-effort, unchanged — a region without an IPv6 range still provisions, and a SLAAC range yields one address per VM. + - Admin: `ip4_count`/`ip6_count` on VM template create/update/read, and `min_ip4`/`max_ip4`/`min_ip6`/`max_ip6` on custom pricing create/update/read/copy. All default to 1, so no plan starts offering a choice until an operator widens it. + - **`host_ssh_keys` on VM status** (issue #154) — `GET /api/v1/vm/{id}` and `GET /api/v1/vm` now return the VM's own SSH host keys as `[{ key_type, public_key, fingerprint_sha256 }]`, so a client can verify the host on first connect instead of accepting whatever key answers. The list is empty until the keys are captured (scanned from the host once the VM is running, public keys only) and is re-captured after a reinstall, which regenerates them. Additive: no existing field changed. Not to be confused with `ssh_key`, which is the customer's authorized key. ### Added diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index ebc7328e..62ee0d4c 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -758,6 +758,17 @@ pub struct ApiCustomVmRequest { /// CPU features as strings (e.g. "AVX2", "AES", "VMX") #[serde(default)] pub cpu_feature: Vec, + /// IPv4 addresses to assign; defaults to 1, the count every order implied + /// before this was selectable. + #[serde(default = "default_ip_count")] + pub ip4_count: u16, + /// IPv6 addresses to assign; defaults to 1. + #[serde(default = "default_ip_count")] + pub ip6_count: u16, +} + +fn default_ip_count() -> u16 { + 1 } impl From for VmCustomTemplate { @@ -777,6 +788,8 @@ impl From for VmCustomTemplate { disk_type: value.disk_type.into(), disk_interface: value.disk_interface.into(), pricing_id: value.pricing_id, + ip4_count: value.ip4_count, + ip6_count: value.ip6_count, cpu_mfg: value .cpu_mfg .and_then(|s| s.parse().ok()) @@ -1495,6 +1508,36 @@ pub enum ApiCreateSubscriptionLineItemRequest { #[cfg(test)] mod tests { + + /// Omitted counts mean the single-IPv4/single-IPv6 offer that predates them + /// being selectable, so an older client keeps ordering exactly what it did. + #[test] + fn custom_vm_request_ip_counts_default_to_one() { + let req: ApiCustomVmRequest = serde_json::from_str( + r#"{"pricing_id": 1, "cpu": 2, "memory": 2147483648, "disk": 21474836480, + "disk_type": "ssd", "disk_interface": "pcie"}"#, + ) + .unwrap(); + assert_eq!(1, req.ip4_count); + assert_eq!(1, req.ip6_count); + + let t: lnvps_db::VmCustomTemplate = req.into(); + assert_eq!(1, t.ip4_count); + assert_eq!(1, t.ip6_count); + } + + /// Requested counts reach the stored spec, including an IPv6-only order. + #[test] + fn custom_vm_request_carries_ip_counts() { + let req: ApiCustomVmRequest = serde_json::from_str( + r#"{"pricing_id": 1, "cpu": 2, "memory": 2147483648, "disk": 21474836480, + "disk_type": "ssd", "disk_interface": "pcie", "ip4_count": 0, "ip6_count": 3}"#, + ) + .unwrap(); + let t: lnvps_db::VmCustomTemplate = req.into(); + assert_eq!(0, t.ip4_count); + assert_eq!(3, t.ip6_count); + } use super::*; /// Exchange-rate feed (#230): a BTC base quotes fiat directly, a fiat base diff --git a/lnvps_api/src/host/mod.rs b/lnvps_api/src/host/mod.rs index e845123d..598e559c 100644 --- a/lnvps_api/src/host/mod.rs +++ b/lnvps_api/src/host/mod.rs @@ -236,6 +236,20 @@ impl FullVmInfo { } } + /// IPv4 and IPv6 address counts this VM's offer specifies. + /// + /// A VM with neither template falls back to one IPv4, which is what every + /// offer implied before counts existed. + pub fn ip_counts(&self) -> (u16, u16) { + if let Some(t) = &self.template { + (t.ip4_count, t.ip6_count) + } else if let Some(t) = &self.custom_template { + (t.ip4_count, t.ip6_count) + } else { + (1, 1) + } + } + pub async fn vm_resources(vm_id: u64, db: Arc) -> Result { let vm = db.get_vm(vm_id).await?; if let Some(t) = vm.template_id { diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs index c9b59927..80db9151 100644 --- a/lnvps_api/src/provisioner/vm.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -444,6 +444,10 @@ impl VmProvisioner { disk_type: disk.kind, disk_interface: disk.interface, pricing_id: pricing.id, + // An imported VM's addresses are attached separately; record the + // single-IPv4 offer every VM had before counts were sellable. + ip4_count: 1, + ip6_count: 1, cpu_mfg: pricing.cpu_mfg, cpu_arch: pricing.cpu_arch, cpu_features: pricing.cpu_features.clone(), @@ -939,6 +943,10 @@ impl VmProvisioner { disk_type: current_template.disk_type, disk_interface: current_template.disk_interface, pricing_id: custom_pricing.id, + // An upgrade changes cpu/memory/disk only; the VM keeps the address + // counts it was sold, so its price must not move on that dimension. + ip4_count: current_template.ip4_count, + ip6_count: current_template.ip6_count, cpu_mfg: current_template.cpu_mfg.clone(), cpu_arch: current_template.cpu_arch.clone(), cpu_features: current_template.cpu_features.clone(), @@ -1064,24 +1072,38 @@ impl SpawnVmContext { return Ok(()); } + let (ip4_count, ip6_count) = self.info.ip_counts(); let network = NetworkProvisioner::new(self.db.clone()); - let ip = network.pick_ip_for_region(self.info.host.region_id).await?; - match ip.ip4 { - Some(v4) => { - let mut assignment = VmIpAssignment { - vm_id: self.info.vm.id, - ip_range_id: v4.range_id, - ip: v4.ip.ip().to_string(), - ..Default::default() - }; - - //generate mac address from ip assignment - self.assign_mac(&mut assignment).await?; - self.info.ips.push(assignment); + // Asking for zero IPv4 is a valid offer; asking for some and not getting + // them is unsatisfiable, so the picker failing here is fatal. + let picked = match network + .pick_ips_for_region(self.info.host.region_id, ip4_count, ip6_count) + .await + { + Ok(p) => p, + Err(e) => op_fatal!("Cannot provision VM: {}", e), + }; + + for v4 in picked.ip4 { + let mut assignment = VmIpAssignment { + vm_id: self.info.vm.id, + ip_range_id: v4.range_id, + ip: v4.ip.ip().to_string(), + ..Default::default() + }; + + // The router-generated vMAC is per-VM, not per-address, so it is only + // generated for the first assignment; later ones reuse the VM's MAC. + self.assign_mac(&mut assignment).await?; + self.info.ips.push(assignment); + if !self.info.ranges.iter().any(|r| r.id == v4.range_id) { + self.info + .ranges + .push(self.db.get_ip_range(v4.range_id).await?); } - None => op_fatal!("Cannot provision VM without an IPv4 address"), } - if let Some(mut v6) = ip.ip6 { + + for mut v6 in picked.ip6 { let assignment = VmProvisioner::v6_to_allocation( &mut v6, self.info.vm.id, @@ -2383,6 +2405,138 @@ mod tests { Ok(()) } + /// A custom order for several IPv4 addresses gets exactly that many, all + /// distinct, and the best-effort IPv6 alongside them. + #[tokio::test] + async fn test_assign_ips_allocates_requested_ip4_count() -> 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 template_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, + ip4_count: 3, + ip6_count: 1, + ..Default::default() + }) + .await?; + + let subscription_line_item_id = make_test_subscription(&db, user.id).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(template_id), + subscription_line_item_id, + ssh_key_id: Some(ssh_key.id), + disk_id: 1, + ..Default::default() + }) + .await?; + + let db_dyn: Arc = db.clone(); + let info = FullVmInfo::load(vm_id, db_dyn.clone()).await?; + let mut ctx = SpawnVmContext { + db: db_dyn.clone(), + network: VmNetworkProvisioner::new(db_dyn.clone(), VmProvisioner::retry_policy()), + host_client: Arc::new(crate::mocks::MockVmHost::new()), + generated_mac: None, + info, + }; + + ctx.assign_ips().await?; + + let v4: Vec<&lnvps_db::VmIpAssignment> = ctx + .info + .ips + .iter() + .filter(|i| i.ip.parse::().map(|a| a.is_ipv4()) == Ok(true)) + .collect(); + assert_eq!(3, v4.len(), "must assign the ordered IPv4 count"); + let unique: std::collections::HashSet<&String> = v4.iter().map(|i| &i.ip).collect(); + assert_eq!(3, unique.len(), "assigned addresses must be distinct"); + assert!( + !ctx.info.vm.mac_address.is_empty(), + "the VM still gets one MAC for the whole set" + ); + + Ok(()) + } + + /// An order the region cannot satisfy fails rather than under-provisioning. + #[tokio::test] + async fn test_assign_ips_fails_when_ip4_short() -> 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?; + + // /30 leaves a single usable address once network, broadcast and gateway + // are reserved. + if let Some(r) = db.ip_range.lock().await.get_mut(&1) { + r.cidr = "10.0.0.0/30".to_string(); + r.gateway = "10.0.0.1/30".to_string(); + r.allocation_mode = lnvps_db::IpRangeAllocationMode::Sequential; + } + + let template_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, + ip4_count: 2, + ip6_count: 0, + ..Default::default() + }) + .await?; + + let subscription_line_item_id = make_test_subscription(&db, user.id).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(template_id), + subscription_line_item_id, + ssh_key_id: Some(ssh_key.id), + disk_id: 1, + ..Default::default() + }) + .await?; + + let db_dyn: Arc = db.clone(); + let info = FullVmInfo::load(vm_id, db_dyn.clone()).await?; + let mut ctx = SpawnVmContext { + db: db_dyn.clone(), + network: VmNetworkProvisioner::new(db_dyn.clone(), VmProvisioner::retry_policy()), + host_client: Arc::new(crate::mocks::MockVmHost::new()), + generated_mac: None, + info, + }; + + assert!( + ctx.assign_ips().await.is_err(), + "an unsatisfiable IPv4 count must not silently under-provision" + ); + + Ok(()) + } + /// Regression: update_line_item_cost_for_custom_vm must update line_item.amount for a VM /// that already uses a custom template after its specs are changed. #[tokio::test] diff --git a/lnvps_api_admin/src/admin/custom_pricing.rs b/lnvps_api_admin/src/admin/custom_pricing.rs index ae8ff052..c382e85e 100644 --- a/lnvps_api_admin/src/admin/custom_pricing.rs +++ b/lnvps_api_admin/src/admin/custom_pricing.rs @@ -100,6 +100,10 @@ impl AdminCustomPricingInfo { max_cpu: pricing.max_cpu, min_memory: pricing.min_memory, max_memory: pricing.max_memory, + min_ip4: pricing.min_ip4, + max_ip4: pricing.max_ip4, + min_ip6: pricing.min_ip6, + max_ip6: pricing.max_ip6, disk_pricing: disk_pricing_info, template_count, disk_iops_read: pricing.disk_iops_read, @@ -198,6 +202,10 @@ async fn admin_create_custom_pricing( max_cpu: req.max_cpu, min_memory: req.min_memory, max_memory: req.max_memory, + min_ip4: req.min_ip4.unwrap_or(1), + max_ip4: req.max_ip4.unwrap_or(1), + min_ip6: req.min_ip6.unwrap_or(1), + max_ip6: req.max_ip6.unwrap_or(1), disk_iops_read: req.disk_iops_read, disk_iops_write: req.disk_iops_write, disk_mbps_read: req.disk_mbps_read, @@ -300,6 +308,18 @@ async fn admin_update_custom_pricing( if let Some(max_memory) = req.max_memory { pricing.max_memory = max_memory; } + if let Some(v) = req.min_ip4 { + pricing.min_ip4 = v; + } + if let Some(v) = req.max_ip4 { + pricing.max_ip4 = v; + } + if let Some(v) = req.min_ip6 { + pricing.min_ip6 = v; + } + if let Some(v) = req.max_ip6 { + pricing.max_ip6 = v; + } if let Some(v) = req.disk_iops_read { pricing.disk_iops_read = v; } @@ -425,6 +445,10 @@ async fn admin_copy_custom_pricing( max_cpu: source_pricing.max_cpu, min_memory: source_pricing.min_memory, max_memory: source_pricing.max_memory, + min_ip4: source_pricing.min_ip4, + max_ip4: source_pricing.max_ip4, + min_ip6: source_pricing.min_ip6, + max_ip6: source_pricing.max_ip6, disk_iops_read: source_pricing.disk_iops_read, disk_iops_write: source_pricing.disk_iops_write, disk_mbps_read: source_pricing.disk_mbps_read, diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 36e2480c..a12954fe 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -1506,6 +1506,8 @@ pub struct AdminVmTemplateInfo { pub disk_interface: ApiDiskInterface, pub cost_plan_id: u64, pub region_id: u64, + pub ip4_count: u16, + pub ip6_count: u16, pub region_name: Option, pub cost_plan_name: Option, pub active_vm_count: i64, // Number of active (non-deleted) VMs using this template @@ -1548,6 +1550,10 @@ pub struct AdminCreateVmTemplateRequest { pub disk_interface: ApiDiskInterface, pub cost_plan_id: Option, // Optional - if not provided, will auto-create cost plan pub region_id: u64, + /// IPv4 addresses included in this offer (defaults to 1) + pub ip4_count: Option, + /// IPv6 addresses included in this offer (defaults to 1) + pub ip6_count: Option, // Cost plan creation fields - used when cost_plan_id is not provided pub cost_plan_name: Option, // Defaults to "{template_name} Cost Plan" /// Cost amount in smallest currency units (cents for fiat, millisats for BTC) - required if cost_plan_id not provided @@ -1606,6 +1612,8 @@ pub struct AdminUpdateVmTemplateRequest { pub disk_interface: Option, pub cost_plan_id: Option, pub region_id: Option, + pub ip4_count: Option, + pub ip6_count: Option, // Cost plan update fields - will update the associated cost plan for this template pub cost_plan_name: Option, /// Cost amount in smallest currency units (cents for fiat, millisats for BTC) @@ -1697,6 +1705,10 @@ pub struct AdminCustomPricingInfo { pub max_cpu: u16, pub min_memory: u64, pub max_memory: u64, + pub min_ip4: u16, + pub max_ip4: u16, + pub min_ip6: u16, + pub max_ip6: u16, pub disk_pricing: Vec, pub template_count: u64, /// Maximum disk read IOPS (None = uncapped) @@ -1774,6 +1786,10 @@ pub struct UpdateCustomPricingRequest { pub max_cpu: Option, pub min_memory: Option, pub max_memory: Option, + pub min_ip4: Option, + pub max_ip4: Option, + pub min_ip6: Option, + pub max_ip6: Option, pub disk_pricing: Option>, /// Maximum disk read IOPS — use `null` to clear #[serde( @@ -1839,6 +1855,14 @@ pub struct CreateCustomPricingRequest { pub max_cpu: u16, pub min_memory: u64, pub max_memory: u64, + /// Minimum IPv4 addresses selectable (defaults to 1) + pub min_ip4: Option, + /// Maximum IPv4 addresses selectable (defaults to 1) + pub max_ip4: Option, + /// Minimum IPv6 addresses selectable (defaults to 1) + pub min_ip6: Option, + /// Maximum IPv6 addresses selectable (defaults to 1) + pub max_ip6: Option, pub disk_pricing: Vec, /// Maximum disk read IOPS (None = uncapped) pub disk_iops_read: Option, diff --git a/lnvps_api_admin/src/admin/vm_templates.rs b/lnvps_api_admin/src/admin/vm_templates.rs index f82f814c..dc4ef9a3 100644 --- a/lnvps_api_admin/src/admin/vm_templates.rs +++ b/lnvps_api_admin/src/admin/vm_templates.rs @@ -70,6 +70,8 @@ impl AdminVmTemplateInfo { disk_interface: template.disk_interface.into(), cost_plan_id: template.cost_plan_id, region_id: template.region_id, + ip4_count: template.ip4_count, + ip6_count: template.ip6_count, region_name: region.map(|r| r.name), cost_plan_name: cost_plan.map(|cp| cp.name), active_vm_count, @@ -199,6 +201,8 @@ async fn admin_create_vm_template( disk_interface: req.disk_interface.into(), cost_plan_id, region_id: req.region_id, + ip4_count: req.ip4_count.unwrap_or(1), + ip6_count: req.ip6_count.unwrap_or(1), disk_iops_read: req.disk_iops_read, disk_iops_write: req.disk_iops_write, disk_mbps_read: req.disk_mbps_read, @@ -319,6 +323,12 @@ async fn admin_update_vm_template( let _region = this.db.get_host_region(region_id).await?; template.region_id = region_id; } + if let Some(v) = req.ip4_count { + template.ip4_count = v; + } + if let Some(v) = req.ip6_count { + template.ip6_count = v; + } if let Some(v) = req.disk_iops_read { template.disk_iops_read = v; } diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 8a29cf58..2154db73 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -968,6 +968,10 @@ async fn create_custom_pricing( max_cpu: max_cpu as u16, min_memory, max_memory, + min_ip4: 1, + max_ip4: 4, + min_ip6: 1, + max_ip6: 4, disk_iops_read: None, disk_iops_write: None, disk_mbps_read: None, diff --git a/lnvps_api_common/src/capacity.rs b/lnvps_api_common/src/capacity.rs index 459f62d9..4c017000 100644 --- a/lnvps_api_common/src/capacity.rs +++ b/lnvps_api_common/src/capacity.rs @@ -156,12 +156,11 @@ impl HostCapacityService { let min_cpu = template.min_cpu; let min_memory = template.min_memory; - // Whether a host has a free IPv4 slot (required for every order). - let host_has_ipv4 = |h: &&HostCapacity| { - h.ranges - .iter() - .any(|r| r.is_ipv4() && r.available_capacity() >= 1) - }; + // Whether a host's region can still supply the smallest IPv4 count + // this plan sells. A plan whose minimum cannot be met is not + // orderable at all. + let min_ip4 = template.min_ip4; + let host_has_ipv4 = move |h: &&HostCapacity| h.available_ip4() >= min_ip4 as u128; // Limit disk maximums based on actual host capacity. // @@ -224,12 +223,25 @@ impl HostCapacityService { let max_memory = servable_max(&|h| h.available_memory()); template.max_cpu = template.max_cpu.min(max_cpu); template.max_memory = template.max_memory.min(max_memory); + + // Never offer more addresses than the region can hand out. Capped + // against the whole region rather than one host: addresses are a + // region resource, unlike cpu/memory/disk. + let max_ip4 = hosts_in_region + .clone() + .map(|h| h.available_ip4()) + .max() + .unwrap_or(0); + template.max_ip4 = template.max_ip4.min(max_ip4.min(u16::MAX as u128) as u16); } - // remove templates with 0 max cpu/ram/disk + // remove templates with 0 max cpu/ram/disk, or that cannot supply the + // addresses they require Ok(templates .into_iter() - .filter(|t| t.max_cpu > 0 && t.max_memory > 0 && !t.disks.is_empty()) + .filter(|t| { + t.max_cpu > 0 && t.max_memory > 0 && !t.disks.is_empty() && t.max_ip4 >= t.min_ip4 + }) .collect()) } @@ -458,10 +470,22 @@ impl HostCapacity { .disks .iter() .any(|d| d.available_capacity() >= template.disk_size()) - && self - .ranges - .iter() - .any(|r| r.is_ipv4() && r.available_capacity() >= 1) + && self.available_ip4() >= template.ip4_count() as u128 + } + + /// Free IPv4 addresses across every range in the host's region. + /// + /// Summed rather than per-range: a VM's addresses may come from any range in + /// the region, so several partly-full ranges can still satisfy one order. + /// + /// IPv6 has no equivalent gate — IPv6 assignment stays best-effort, so a + /// region without a v6 range must not become unsellable. + pub fn available_ip4(&self) -> u128 { + self.ranges + .iter() + .filter(|r| r.is_ipv4()) + .map(|r| r.available_capacity()) + .sum() } } @@ -833,6 +857,8 @@ mod tests { disk_type: DiskType::SSD, disk_interface: DiskInterface::PCIe, region_id: 1, + ip4_count: 1, + ip6_count: 1, ..Default::default() } } @@ -1132,6 +1158,60 @@ mod tests { ); } + /// A template asking for more IPv4 addresses than the region has free must + /// not be accommodated, even though a single address is available. + #[test] + fn can_accommodate_respects_ip4_count() { + let mut cap = make_host_capacity(CpuMfg::Unknown, CpuArch::Unknown, vec![]); + cap.ranges = vec![IPRangeCapacity { + range: IpRange { + id: 1, + cidr: "10.0.0.0/29".to_string(), + gateway: "192.168.1.1".to_string(), + enabled: true, + region_id: 1, + use_full_range: false, + ..Default::default() + }, + // 8 total - 2 boundary = 6 usable, 4 already taken + usage: 4, + }]; + assert_eq!(2, cap.available_ip4()); + + let mut template = make_template(CpuMfg::Unknown, CpuArch::Unknown, vec![]); + template.ip4_count = 2; + assert!(cap.can_accommodate(&template)); + + template.ip4_count = 3; + assert!( + !cap.can_accommodate(&template), + "must not sell more addresses than the region can supply" + ); + } + + /// An IPv6-only offer is sellable in a region with no IPv4 capacity left. + #[test] + fn can_accommodate_ip4_count_zero_needs_no_v4() { + let mut cap = make_host_capacity(CpuMfg::Unknown, CpuArch::Unknown, vec![]); + cap.ranges = vec![IPRangeCapacity { + range: IpRange { + id: 1, + cidr: "10.0.0.0/30".to_string(), + gateway: "192.168.1.1".to_string(), + enabled: true, + region_id: 1, + use_full_range: false, + ..Default::default() + }, + usage: 2, + }]; + assert_eq!(0, cap.available_ip4()); + + let mut template = make_template(CpuMfg::Unknown, CpuArch::Unknown, vec![]); + template.ip4_count = 0; + assert!(cap.can_accommodate(&template)); + } + // ── apply_host_capacity_limits tests ──────────────────────────────────── /// Helper to build a minimal ApiCustomTemplateParams for region 1 @@ -1157,6 +1237,10 @@ mod tests { min_cpu: 1, min_memory: GB, max_memory, + min_ip4: 1, + max_ip4: 1, + min_ip6: 1, + max_ip6: 1, disks: vec![ApiCustomTemplateDiskParam { min_disk: GB, max_disk: 100 * GB, diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index b537ad6a..c92d60c1 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -140,6 +140,8 @@ impl MockDb { disk_interface: DiskInterface::PCIe, cost_plan_id: 1, region_id: 1, + ip4_count: 1, + ip6_count: 1, disk_iops_read: None, disk_iops_write: None, disk_mbps_read: None, diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index 97fb92bf..befc314f 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -28,6 +28,10 @@ pub trait Template { fn cpu_arch(&self) -> CpuArch; /// Required CPU feature flags. An empty list means "any". fn cpu_features(&self) -> &[CpuFeature]; + /// Number of IPv4 addresses the offer includes. + fn ip4_count(&self) -> u16; + /// Number of IPv6 addresses the offer includes. + fn ip6_count(&self) -> u16; } impl Template for VmTemplate { @@ -62,6 +66,14 @@ impl Template for VmTemplate { fn cpu_features(&self) -> &[CpuFeature] { &self.cpu_features } + + fn ip4_count(&self) -> u16 { + self.ip4_count + } + + fn ip6_count(&self) -> u16 { + self.ip6_count + } } impl Template for VmCustomTemplate { @@ -96,6 +108,14 @@ impl Template for VmCustomTemplate { fn cpu_features(&self) -> &[CpuFeature] { &self.cpu_features } + + fn ip4_count(&self) -> u16 { + self.ip4_count + } + + fn ip6_count(&self) -> u16 { + self.ip6_count + } } impl ApiVmTemplate { @@ -150,6 +170,8 @@ impl ApiVmTemplate { name: region.name, company_id: region.company_id, }, + ip4_count: template.ip4_count, + ip6_count: template.ip6_count, }) } @@ -209,6 +231,8 @@ impl ApiVmTemplate { name: region.name.clone(), company_id: region.company_id, }, + ip4_count: template.ip4_count, + ip6_count: template.ip6_count, }) } } @@ -502,6 +526,11 @@ pub struct ApiVmTemplate { pub cpu_mfg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cpu_arch: Option, + /// IPv4 addresses included + pub ip4_count: u16, + /// IPv6 addresses included. Assignment is best-effort: a region without an + /// IPv6 range still provisions, so a VM may hold fewer than this. + pub ip6_count: u16, } #[derive(Serialize, Deserialize, Clone, Copy)] @@ -832,6 +861,14 @@ pub struct ApiCustomTemplateParams { pub min_cpu: u16, pub min_memory: u64, pub max_memory: u64, + /// Minimum IPv4 addresses selectable on this plan + pub min_ip4: u16, + /// Maximum IPv4 addresses selectable, already capped by region capacity + pub max_ip4: u16, + /// Minimum IPv6 addresses selectable on this plan + pub min_ip6: u16, + /// Maximum IPv6 addresses selectable on this plan + pub max_ip6: u16, pub disks: Vec, } @@ -868,6 +905,10 @@ impl ApiCustomTemplateParams { min_cpu: pricing.min_cpu, min_memory: pricing.min_memory, max_memory: pricing.max_memory, + min_ip4: pricing.min_ip4, + max_ip4: pricing.max_ip4, + min_ip6: pricing.min_ip6, + max_ip6: pricing.max_ip6, disks: disks .iter() .filter(|d| d.pricing_id == pricing.id) diff --git a/lnvps_api_common/src/network.rs b/lnvps_api_common/src/network.rs index ccfd6f77..87e58978 100644 --- a/lnvps_api_common/src/network.rs +++ b/lnvps_api_common/src/network.rs @@ -39,6 +39,13 @@ pub struct AvailableIps { pub ip6: Option, } +/// Addresses picked for one VM, in the counts its offer specifies. +#[derive(Debug, Clone, Default)] +pub struct PickedIps { + pub ip4: Vec, + pub ip6: Vec, +} + #[derive(Debug, Clone)] pub struct AvailableIp { pub ip: IpNetwork, @@ -71,6 +78,78 @@ impl NetworkProvisioner { self.pick_ip_kind_for_region(region_id, None).await } + /// Pick `ip4_count` IPv4 addresses and up to `ip6_count` IPv6 addresses in one + /// region, spanning ranges as needed. + /// + /// IPv4 is all-or-nothing: an order that asks for addresses the region cannot + /// supply is unsatisfiable, never quietly under-provisioned. IPv6 stays + /// best-effort, as it always has been, so a region with no IPv6 range still + /// provisions. + /// + /// A SLAAC/EUI-64 range yields at most one address per VM, because the address + /// is derived from the VM's single MAC. + pub async fn pick_ips_for_region( + &self, + region_id: u64, + ip4_count: u16, + ip6_count: u16, + ) -> Result { + let mut ip_ranges = self.db.list_ip_range_in_region(region_id).await?; + if ip_ranges.is_empty() { + bail!("No ip range found in this region"); + } + ip_ranges.shuffle(&mut rand::rng()); + + let mut picked = PickedIps::default(); + let mut taken: HashSet = HashSet::new(); + + for range in &ip_ranges { + let range_cidr: IpNetwork = match range.cidr.parse() { + Ok(c) => c, + Err(e) => { + warn!("Skipping unparseable ip range {}: {}", range.cidr, e); + continue; + } + }; + let want = if range_cidr.is_ipv4() { + ip4_count as usize - picked.ip4.len().min(ip4_count as usize) + } else if matches!(range.allocation_mode, IpRangeAllocationMode::SlaacEui64) { + // One MAC, one derived address. + usize::from(picked.ip6.is_empty() && ip6_count > 0) + } else { + ip6_count as usize - picked.ip6.len().min(ip6_count as usize) + }; + + for _ in 0..want { + match self.pick_ip_from_range_excluding(range, &taken).await { + Ok(ip) => { + taken.insert(ip.ip.ip()); + if range_cidr.is_ipv4() { + picked.ip4.push(ip); + } else { + picked.ip6.push(ip); + } + } + Err(e) => { + // A full or broken range is not fatal on its own; another + // range in the region may still have space. + warn!("Failed to pick ip in range {}: {}", range.cidr, e); + break; + } + } + } + } + + if picked.ip4.len() < ip4_count as usize { + bail!( + "Only {} of {} IPv4 addresses available in this region", + picked.ip4.len(), + ip4_count + ); + } + Ok(picked) + } + pub async fn pick_ip_kind_for_region( &self, region_id: u64, @@ -133,10 +212,25 @@ impl NetworkProvisioner { } pub async fn pick_ip_from_range(&self, range: &IpRange) -> Result { + self.pick_ip_from_range_excluding(range, &HashSet::new()) + .await + } + + /// Pick a free IP, additionally avoiding `exclude`. + /// + /// Callers picking several addresses for one VM need this: nothing is + /// persisted until the whole assignment succeeds, so without it the second + /// pick can hand back the address the first one just took. + pub async fn pick_ip_from_range_excluding( + &self, + range: &IpRange, + exclude: &HashSet, + ) -> Result { let range_cidr: IpNetwork = range.cidr.parse()?; let ips = self.db.list_vm_ip_assignments_in_range(range.id).await?; // Parse stored IPs (stored as plain IP addresses) let mut ips: HashSet = ips.iter().filter_map(|i| i.ip.parse().ok()).collect(); + ips.extend(exclude.iter().copied()); let gateway: IpNetwork = parse_gateway(&range.gateway)?; @@ -861,4 +955,57 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("IPv4 ranges")); } + + #[tokio::test] + async fn pick_ips_for_region_allocates_distinct_v4() { + env_logger::try_init().ok(); + let db = MockDb::default(); + let db: Arc = Arc::new(db); + let mgr = NetworkProvisioner::new(db.clone()); + + let picked = mgr.pick_ips_for_region(1, 3, 1).await.expect("picked"); + assert_eq!(3, picked.ip4.len()); + let unique: HashSet = picked.ip4.iter().map(|i| i.ip.ip()).collect(); + assert_eq!(3, unique.len(), "every picked address must be distinct"); + // The mock region's only IPv6 range is SLAAC, which yields one address. + assert_eq!(1, picked.ip6.len()); + } + + #[tokio::test] + async fn pick_ips_for_region_fails_when_v4_short() { + env_logger::try_init().ok(); + let db = MockDb::default(); + // A /30 with reserved network/broadcast and gateway leaves one usable address. + if let Some(r) = db.ip_range.lock().await.get_mut(&1) { + r.cidr = "10.0.0.0/30".to_string(); + r.gateway = "10.0.0.1/30".to_string(); + r.allocation_mode = IpRangeAllocationMode::Sequential; + } + let db: Arc = Arc::new(db); + let mgr = NetworkProvisioner::new(db.clone()); + + assert!(mgr.pick_ips_for_region(1, 1, 0).await.is_ok()); + let err = mgr + .pick_ips_for_region(1, 2, 0) + .await + .expect_err("must not under-provision IPv4"); + assert!(err.to_string().contains("IPv4"), "{err}"); + } + + #[tokio::test] + async fn pick_ips_for_region_v6_is_best_effort() { + env_logger::try_init().ok(); + let db = MockDb::default(); + // Region with no IPv6 range at all. + db.ip_range.lock().await.remove(&2); + let db: Arc = Arc::new(db); + let mgr = NetworkProvisioner::new(db.clone()); + + let picked = mgr.pick_ips_for_region(1, 1, 2).await.expect("picked"); + assert_eq!(1, picked.ip4.len()); + assert!( + picked.ip6.is_empty(), + "missing IPv6 must not fail provisioning" + ); + } } diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 27c851e6..394374db 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -758,25 +758,11 @@ impl PricingEngine { ) -> Result { let pricing = db.get_custom_pricing(template.pricing_id).await?; let pricing_disk = db.list_custom_pricing_disk(pricing.id).await?; - let ips = db.list_vm_ip_assignments(vm_id).await?; - let v4s = ips - .iter() - .filter(|i| { - IpNetwork::from_str(&i.ip) - .map(|i| i.is_ipv4()) - .unwrap_or(false) - }) - .count() - .max(1); // must have at least 1 - let v6s = ips - .iter() - .filter(|i| { - IpNetwork::from_str(&i.ip) - .map(|i| i.is_ipv6()) - .unwrap_or(false) - }) - .count() - .max(1); // must have at least 1 + // Counts come from the ordered spec, not from what is currently assigned: + // the price must be quotable before any address exists, and must not move + // when provisioning temporarily holds fewer addresses than were sold. + let v4s = template.ip4_count as u64; + let v6s = template.ip6_count as u64; // Match disk pricing on BOTH kind and interface — pricing rows are keyed // by (kind, interface), so matching on kind alone can bill the wrong rate // (or a rate for an interface the user never requested). @@ -803,8 +789,8 @@ impl PricingEngine { 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; - let ip4_cost = pricing.ip4_cost * v4s as u64; - let ip6_cost = pricing.ip6_cost * v6s as u64; + let ip4_cost = pricing.ip4_cost * v4s; + let ip6_cost = pricing.ip6_cost * v6s; let currency: Currency = if let Ok(p) = pricing.currency.parse() { p @@ -866,6 +852,22 @@ impl PricingEngine { disk_pricing.max_disk_size ); } + if template.ip4_count < pricing.min_ip4 || template.ip4_count > pricing.max_ip4 { + bail!( + "IPv4 count {} out of range ({}-{})", + template.ip4_count, + pricing.min_ip4, + pricing.max_ip4 + ); + } + if template.ip6_count < pricing.min_ip6 || template.ip6_count > pricing.max_ip6 { + bail!( + "IPv6 count {} out of range ({}-{})", + template.ip6_count, + pricing.min_ip6, + pricing.max_ip6 + ); + } Ok(()) } @@ -1259,6 +1261,8 @@ impl PricingEngine { cpu_mfg, cpu_arch, cpu_features, + ip4_count, + ip6_count, ) = if let Some(template_id) = vm.template_id { let template = self.db.get_vm_template(template_id).await?; ( @@ -1279,6 +1283,8 @@ impl PricingEngine { template.cpu_mfg, template.cpu_arch, template.cpu_features, + template.ip4_count, + template.ip6_count, ) } else if let Some(custom_template_id) = vm.custom_template_id { let custom_template = self.db.get_custom_vm_template(custom_template_id).await?; @@ -1294,6 +1300,8 @@ impl PricingEngine { custom_template.cpu_mfg, custom_template.cpu_arch, custom_template.cpu_features, + custom_template.ip4_count, + custom_template.ip6_count, ) } else { bail!("VM must have either a standard template or custom template to upgrade"); @@ -1327,6 +1335,10 @@ impl PricingEngine { disk_type, disk_interface, pricing_id: pricing.id, + // An upgrade changes cpu/memory/disk only; the address counts the VM + // was sold carry over untouched. + ip4_count, + ip6_count, cpu_mfg, cpu_arch, cpu_features, @@ -1757,6 +1769,10 @@ mod tests { max_cpu: 16, min_memory: 1 * crate::GB, max_memory: 64 * crate::GB, + min_ip4: 1, + max_ip4: 4, + min_ip6: 1, + max_ip6: 4, ..Default::default() }, ); @@ -1771,6 +1787,8 @@ mod tests { disk_type: DiskType::SSD, disk_interface: Default::default(), pricing_id: 1, + ip4_count: 1, + ip6_count: 1, ..Default::default() }, ); @@ -1937,6 +1955,72 @@ mod tests { Ok(()) } + /// Address counts come from the ordered spec and multiply the per-address + /// price, so an order for extra addresses costs more. + #[tokio::test] + async fn custom_pricing_scales_with_ip_counts() -> Result<()> { + let db = MockDb::default(); + add_custom_pricing(&db).await; + let db: Arc = Arc::new(db); + + let mut template = db.get_custom_vm_template(1).await?; + template.ip4_count = 3; + template.ip6_count = 2; + + let price = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await?; + assert_eq!(150, price.ip4_cost); + assert_eq!(10, price.ip6_cost); + assert_eq!(960, price.total()); + + // Priced the same before the VM exists (no assignments to count). + let quote = PricingEngine::get_custom_vm_cost_amount(&db, 0, &template).await?; + assert_eq!(price.total(), quote.total()); + + Ok(()) + } + + /// An IPv6-only order pays nothing for IPv4. + #[tokio::test] + async fn custom_pricing_ip4_count_zero_is_free() -> Result<()> { + let db = MockDb::default(); + add_custom_pricing(&db).await; + let db: Arc = Arc::new(db); + + let mut template = db.get_custom_vm_template(1).await?; + template.ip4_count = 0; + let price = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await?; + assert_eq!(0, price.ip4_cost); + + Ok(()) + } + + /// Counts outside the plan's range are refused at order/upgrade time. + #[tokio::test] + async fn validate_custom_vm_spec_checks_ip_counts() -> Result<()> { + let db = MockDb::default(); + add_custom_pricing(&db).await; + let db: Arc = Arc::new(db); + + let base = db.get_custom_vm_template(1).await?; + PricingEngine::validate_custom_vm_spec(&db, &base).await?; + + let mut too_many_v4 = base.clone(); + too_many_v4.ip4_count = 5; + let err = PricingEngine::validate_custom_vm_spec(&db, &too_many_v4) + .await + .expect_err("above max_ip4"); + assert!(err.to_string().contains("IPv4 count"), "{err}"); + + let mut too_few_v6 = base.clone(); + too_few_v6.ip6_count = 0; + let err = PricingEngine::validate_custom_vm_spec(&db, &too_few_v6) + .await + .expect_err("below min_ip6"); + assert!(err.to_string().contains("IPv6 count"), "{err}"); + + Ok(()) + } + /// Regression: sub-GB memory/disk must be billed (rounded up), not truncated /// to 0. Previously `memory / GB` floored a request just under 2 GB to 1 GB /// (and anything under 1 GB to free). @@ -2383,6 +2467,10 @@ mod tests { max_cpu: 16, min_memory: 1 * crate::GB, max_memory: 64 * crate::GB, + min_ip4: 1, + max_ip4: 4, + min_ip6: 1, + max_ip6: 4, ..Default::default() }, ); @@ -3322,6 +3410,10 @@ mod tests { max_cpu: 16, min_memory: crate::GB, max_memory: 64 * crate::GB, + min_ip4: 1, + max_ip4: 4, + min_ip6: 1, + max_ip6: 4, ..Default::default() }, ); diff --git a/lnvps_db/migrations/20260730130000_template_ip_counts.sql b/lnvps_db/migrations/20260730130000_template_ip_counts.sql new file mode 100644 index 00000000..b1a6b4d2 --- /dev/null +++ b/lnvps_db/migrations/20260730130000_template_ip_counts.sql @@ -0,0 +1,51 @@ +-- IP counts as a sellable dimension of an offer, alongside cpu/memory/disk. +-- +-- Defaults reproduce the previously implicit offer: exactly one IPv4 and one +-- IPv6 per VM. Custom pricing min/max default to that same single value, so no +-- plan silently starts offering a choice until an operator widens the range. +ALTER TABLE vm_template + ADD COLUMN ip4_count smallint unsigned not null default 1, + ADD COLUMN ip6_count smallint unsigned not null default 1; + +ALTER TABLE vm_custom_pricing + ADD COLUMN min_ip4 smallint unsigned not null default 1, + ADD COLUMN max_ip4 smallint unsigned not null default 1, + ADD COLUMN min_ip6 smallint unsigned not null default 1, + ADD COLUMN max_ip6 smallint unsigned not null default 1; + +ALTER TABLE vm_custom_template + ADD COLUMN ip4_count smallint unsigned not null default 1, + ADD COLUMN ip6_count smallint unsigned not null default 1; + +-- Existing custom VMs are billed from what is actually assigned to them, so +-- adopt those counts as the stored spec: the count becomes authoritative for +-- pricing, and this keeps every existing renewal at the same amount. IPv6 +-- addresses are the ones containing a colon. +UPDATE vm_custom_template ct + JOIN vm v ON v.custom_template_id = ct.id +SET ct.ip4_count = GREATEST(1, (SELECT COUNT(*) + FROM vm_ip_assignment a + WHERE a.vm_id = v.id + AND a.deleted = 0 + AND a.ip NOT LIKE '%:%')), + ct.ip6_count = GREATEST(1, (SELECT COUNT(*) + FROM vm_ip_assignment a + WHERE a.vm_id = v.id + AND a.deleted = 0 + AND a.ip LIKE '%:%')); + +-- A plan must be able to price what its existing VMs already hold, otherwise +-- those VMs fail spec validation on upgrade. +UPDATE vm_custom_pricing p +SET p.max_ip4 = GREATEST(p.max_ip4, COALESCE((SELECT MAX(ct.ip4_count) + FROM vm_custom_template ct + WHERE ct.pricing_id = p.id), 1)), + p.max_ip6 = GREATEST(p.max_ip6, COALESCE((SELECT MAX(ct.ip6_count) + FROM vm_custom_template ct + WHERE ct.pricing_id = p.id), 1)), + p.min_ip4 = LEAST(p.min_ip4, COALESCE((SELECT MIN(ct.ip4_count) + FROM vm_custom_template ct + WHERE ct.pricing_id = p.id), 1)), + p.min_ip6 = LEAST(p.min_ip6, COALESCE((SELECT MIN(ct.ip6_count) + FROM vm_custom_template ct + WHERE ct.pricing_id = p.id), 1)); diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 22cbd713..e3945c06 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1178,6 +1178,10 @@ pub struct VmTemplate { pub disk_interface: DiskInterface, pub cost_plan_id: u64, pub region_id: u64, + /// Number of IPv4 addresses this offer includes + pub ip4_count: u16, + /// Number of IPv6 addresses this offer includes + pub ip6_count: u16, /// Maximum disk read IOPS (None = uncapped) pub disk_iops_read: Option, /// Maximum disk write IOPS (None = uncapped) @@ -1205,6 +1209,10 @@ pub struct VmCustomTemplate { pub disk_type: DiskType, pub disk_interface: DiskInterface, pub pricing_id: u64, + /// Number of IPv4 addresses ordered + pub ip4_count: u16, + /// Number of IPv6 addresses ordered + pub ip6_count: u16, pub cpu_mfg: CpuMfg, pub cpu_arch: CpuArch, pub cpu_features: CommaSeparated, @@ -1253,6 +1261,14 @@ pub struct VmCustomPricing { pub min_memory: u64, /// Maximum memory in bytes pub max_memory: u64, + /// Minimum IPv4 addresses allowed + pub min_ip4: u16, + /// Maximum IPv4 addresses allowed + pub max_ip4: u16, + /// Minimum IPv6 addresses allowed + pub min_ip6: u16, + /// Maximum IPv6 addresses allowed + pub max_ip6: u16, /// Maximum disk read IOPS (None = uncapped) pub disk_iops_read: Option, /// Maximum disk write IOPS (None = uncapped) diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 4b1ffb9b..cf8dd4bf 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -997,7 +997,7 @@ 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,cpu_mfg,cpu_arch,cpu_features,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id,disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limit) 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,ip4_count,ip6_count,disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limit) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) returning id") .bind(&template.name) .bind(template.enabled) .bind(template.created) @@ -1012,6 +1012,8 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(template.disk_interface) .bind(template.cost_plan_id) .bind(template.region_id) + .bind(template.ip4_count) + .bind(template.ip6_count) .bind(template.disk_iops_read) .bind(template.disk_iops_write) .bind(template.disk_mbps_read) @@ -1545,13 +1547,15 @@ 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,cpu_mfg,cpu_arch,cpu_features,disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limit) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) returning id") + Ok(sqlx::query("insert into vm_custom_template(cpu,memory,disk_size,disk_type,disk_interface,pricing_id,ip4_count,ip6_count,cpu_mfg,cpu_arch,cpu_features,disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limit) 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.ip4_count) + .bind(template.ip6_count) .bind(&template.cpu_mfg) .bind(&template.cpu_arch) .bind(&template.cpu_features) @@ -1567,13 +1571,15 @@ impl LNVpsDbBase for LNVpsDbMysql { } 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=?, cpu_mfg=?, cpu_arch=?, cpu_features=?, disk_iops_read=?, disk_iops_write=?, disk_mbps_read=?, disk_mbps_write=?, network_mbps=?, cpu_limit=? where id=?") + sqlx::query("update vm_custom_template set cpu=?, memory=?, disk_size=?, disk_type=?, disk_interface=?, pricing_id=?, ip4_count=?, ip6_count=?, cpu_mfg=?, cpu_arch=?, cpu_features=?, disk_iops_read=?, disk_iops_write=?, disk_mbps_read=?, disk_mbps_write=?, network_mbps=?, cpu_limit=? 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.ip4_count) + .bind(template.ip6_count) .bind(&template.cpu_mfg) .bind(&template.cpu_arch) .bind(&template.cpu_features) @@ -5427,7 +5433,7 @@ impl AdminDb for LNVpsDbMysql { r#"UPDATE vm_template SET name = ?, enabled = ?, expires = ?, cpu = ?, cpu_mfg = ?, cpu_arch = ?, cpu_features = ?, memory = ?, disk_size = ?, disk_type = ?, disk_interface = ?, - cost_plan_id = ?, region_id = ?, + cost_plan_id = ?, region_id = ?, ip4_count = ?, ip6_count = ?, disk_iops_read = ?, disk_iops_write = ?, disk_mbps_read = ?, disk_mbps_write = ?, network_mbps = ?, cpu_limit = ? WHERE id = ?"#, @@ -5445,6 +5451,8 @@ impl AdminDb for LNVpsDbMysql { .bind(template.disk_interface) .bind(template.cost_plan_id) .bind(template.region_id) + .bind(template.ip4_count) + .bind(template.ip6_count) .bind(template.disk_iops_read) .bind(template.disk_iops_write) .bind(template.disk_mbps_read) @@ -5525,8 +5533,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_mfg, cpu_arch, cpu_features, cpu_cost, memory_cost, ip4_cost, ip6_cost, min_cpu, max_cpu, min_memory, max_memory, disk_iops_read, disk_iops_write, disk_mbps_read, disk_mbps_write, network_mbps, cpu_limit) - 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, min_ip4, max_ip4, min_ip6, max_ip6, disk_iops_read, disk_iops_write, disk_mbps_read, disk_mbps_write, network_mbps, cpu_limit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#; let result = sqlx::query(query) @@ -5547,6 +5555,10 @@ impl AdminDb for LNVpsDbMysql { .bind(pricing.max_cpu) .bind(pricing.min_memory) .bind(pricing.max_memory) + .bind(pricing.min_ip4) + .bind(pricing.max_ip4) + .bind(pricing.min_ip6) + .bind(pricing.max_ip6) .bind(pricing.disk_iops_read) .bind(pricing.disk_iops_write) .bind(pricing.disk_mbps_read) @@ -5565,6 +5577,7 @@ impl AdminDb for LNVpsDbMysql { SET name = ?, enabled = ?, 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 = ?, + min_ip4 = ?, max_ip4 = ?, min_ip6 = ?, max_ip6 = ?, disk_iops_read = ?, disk_iops_write = ?, disk_mbps_read = ?, disk_mbps_write = ?, network_mbps = ?, cpu_limit = ? WHERE id = ? @@ -5587,6 +5600,10 @@ impl AdminDb for LNVpsDbMysql { .bind(pricing.max_cpu) .bind(pricing.min_memory) .bind(pricing.max_memory) + .bind(pricing.min_ip4) + .bind(pricing.max_ip4) + .bind(pricing.min_ip6) + .bind(pricing.max_ip6) .bind(pricing.disk_iops_read) .bind(pricing.disk_iops_write) .bind(pricing.disk_mbps_read) @@ -5686,8 +5703,9 @@ impl AdminDb for LNVpsDbMysql { async fn insert_custom_template(&self, template: &VmCustomTemplate) -> DbResult { let query = r#" INSERT INTO vm_custom_template (cpu, memory, disk_size, disk_type, disk_interface, pricing_id, + ip4_count, ip6_count, disk_iops_read, disk_iops_write, disk_mbps_read, disk_mbps_write, network_mbps, cpu_limit) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#; let result = sqlx::query(query) @@ -5697,6 +5715,8 @@ impl AdminDb for LNVpsDbMysql { .bind(template.disk_type as u16) .bind(template.disk_interface as u16) .bind(template.pricing_id) + .bind(template.ip4_count) + .bind(template.ip6_count) .bind(template.disk_iops_read) .bind(template.disk_iops_write) .bind(template.disk_mbps_read) @@ -5713,6 +5733,7 @@ impl AdminDb for LNVpsDbMysql { let query = r#" UPDATE vm_custom_template SET cpu = ?, memory = ?, disk_size = ?, disk_type = ?, disk_interface = ?, pricing_id = ?, + ip4_count = ?, ip6_count = ?, disk_iops_read = ?, disk_iops_write = ?, disk_mbps_read = ?, disk_mbps_write = ?, network_mbps = ?, cpu_limit = ? WHERE id = ? @@ -5725,6 +5746,8 @@ impl AdminDb for LNVpsDbMysql { .bind(template.disk_type as u16) .bind(template.disk_interface as u16) .bind(template.pricing_id) + .bind(template.ip4_count) + .bind(template.ip6_count) .bind(template.disk_iops_read) .bind(template.disk_iops_write) .bind(template.disk_mbps_read) diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index 750637e1..d1a7fc11 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -220,6 +220,10 @@ mod tests { "max_cpu": 8, "min_memory": 1073741824_u64, "max_memory": 17179869184_u64, + "min_ip4": 1, + "max_ip4": 2, + "min_ip6": 0, + "max_ip6": 1, "disk_pricing": [{ "kind": "ssd", "interface": "pcie", @@ -1677,6 +1681,7 @@ mod tests { "disk": 10737418240_u64, "disk_type": "ssd", "disk_interface": "pcie", + "image_id": image_id, "ssh_key_id": ssh_key_id, "reason": "e2e admin custom create" @@ -1728,6 +1733,63 @@ mod tests { "unknown disk_type must be rejected" ); + // ---------------------------------------------------------------- + // 19c. Custom VM order with an explicit IPv4 count + // ---------------------------------------------------------------- + let ip_body = serde_json::json!({ + "pricing_id": custom_pricing_id, + "cpu": 1, + "memory": 1073741824_u64, + "disk": 10737418240_u64, + "disk_type": "ssd", + "disk_interface": "pcie", + "ip4_count": 2, + "ip6_count": 0, + "image_id": image_id, + "ssh_key_id": ssh_key_id + }); + let mut multi_ip_vm_id: Option = None; + let resp = user + .post_auth("/api/v1/vm/custom-template", &ip_body) + .await + .unwrap(); + if resp.status() == StatusCode::OK { + let vm = serde_json::from_str::(&resp.text().await.unwrap()).unwrap(); + let id = vm["data"]["id"].as_u64().unwrap(); + multi_ip_vm_id = Some(id); + assert_eq!( + 2, + vm["data"]["template"]["ip4_count"].as_u64().unwrap(), + "ordered IPv4 count must be reflected on the VM's template" + ); + assert_eq!(0, vm["data"]["template"]["ip6_count"].as_u64().unwrap()); + // Two addresses at 200 cents each, so the IPv4 share alone exceeds + // the single-address price this plan used to imply. + let amount = vm["data"]["template"]["cost_plan"]["amount"] + .as_u64() + .unwrap(); + assert!(amount >= 400, "2x IPv4 must be billed, got {amount}"); + eprintln!("Ordered custom VM {id} with 2x IPv4 (amount {amount})"); + } else { + eprintln!( + "Multi-IP custom VM order returned {} (expected if provisioner unavailable)", + resp.status() + ); + } + + // A count outside the plan's range is refused. + let mut bad = ip_body.clone(); + bad["ip4_count"] = serde_json::json!(9); + let resp = user + .post_auth("/api/v1/vm/custom-template", &bad) + .await + .unwrap(); + assert_ne!( + resp.status(), + StatusCode::OK, + "an out-of-range IPv4 count must not be orderable" + ); + // ---------------------------------------------------------------- // 20. Cleanup: hard-delete VMs and all infrastructure via DB // The worker cannot reach fake hosts, so API-level VM deletion @@ -1746,10 +1808,15 @@ mod tests { crate::db::hard_delete_vm(&pool, cvm_id).await.unwrap(); eprintln!("Hard-deleted custom VM {cvm_id}"); } + crate::db::hard_delete_vm(&pool, admin_custom_vm_id) .await .unwrap(); eprintln!("Hard-deleted admin custom VM {admin_custom_vm_id}"); + if let Some(cvm_id) = multi_ip_vm_id { + crate::db::hard_delete_vm(&pool, cvm_id).await.unwrap(); + eprintln!("Hard-deleted multi-IP custom VM {cvm_id}"); + } crate::db::hard_delete_referral(&pool, referral_id) .await .unwrap(); From f77ab1ae2f55d9f4380050f1e02159040ca98776 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 30 Jul 2026 14:27:42 +0100 Subject: [PATCH 2/4] Drop the unused vm_id from custom VM pricing --- lnvps_api/src/api/routes.rs | 2 +- lnvps_api/src/provisioner/vm.rs | 13 ++++----- lnvps_api_admin/src/admin/custom_pricing.rs | 19 +++++++++++++- lnvps_api_common/src/model.rs | 6 ++--- lnvps_api_common/src/network.rs | 4 +-- lnvps_api_common/src/pricing.rs | 25 +++++++----------- lnvps_e2e/src/admin_api.rs | 29 +++++++++++++++++++++ 7 files changed, 67 insertions(+), 31 deletions(-) diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index e74110a6..ad4217c9 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -1065,7 +1065,7 @@ async fn v1_custom_template_calc( // Reject out-of-range specs so the order form surfaces the error early. PricingEngine::validate_custom_vm_spec(&this.db, &template).await?; - let price = PricingEngine::get_custom_vm_cost_amount(&this.db, 0, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&this.db, &template).await?; let amount = CurrencyAmount::from_u64(price.currency, price.total()); // Include conversions to the other supported currencies, like the template // listing's `other_price`. diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs index 80db9151..9ff4b2e4 100644 --- a/lnvps_api/src/provisioner/vm.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -352,8 +352,7 @@ impl VmProvisioner { li.name = format!("VM{} - {}", new_vm.id, pricing.name); // Record the base monthly amount now that the VM id is known. With no IP // assignments yet this prices the base config plus the minimum 1x IPv4/IPv6. - let price = - PricingEngine::get_custom_vm_cost_amount(&self.db, new_vm.id, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&self.db, &template).await?; li.amount = price.total(); self.db.update_subscription_line_item(&li).await?; @@ -532,8 +531,7 @@ impl VmProvisioner { .get_subscription_line_item(subscription_line_item_id) .await?; li.name = format!("VM{} - {}", new_vm.id, pricing.name); - let price = - PricingEngine::get_custom_vm_cost_amount(&self.db, new_vm.id, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&self.db, &template).await?; li.amount = price.total(); self.db.update_subscription_line_item(&li).await?; @@ -850,7 +848,7 @@ impl VmProvisioner { // Calculate the new base-currency cost for the new custom template and update the line // item's amount so the displayed subscription cost reflects the upgraded specs. let new_price = - PricingEngine::get_custom_vm_cost_amount(&self.db, vm_id, &new_custom_template).await?; + PricingEngine::get_custom_vm_cost_amount(&self.db, &new_custom_template).await?; // Update the line item: mark as VmRenewal (no longer VmUpgrade), store the new config, // and update the renewal amount to the new template's base-currency cost. @@ -875,8 +873,7 @@ impl VmProvisioner { .ok_or_else(|| anyhow::anyhow!("VM does not have a custom template"))?; let template = self.db.get_custom_vm_template(custom_template_id).await?; - let new_price = - PricingEngine::get_custom_vm_cost_amount(&self.db, vm_id, &template).await?; + let new_price = PricingEngine::get_custom_vm_cost_amount(&self.db, &template).await?; let mut line_item = self .db @@ -2006,7 +2003,7 @@ mod tests { // Directly insert VM (bypassing provision_custom) to simulate an existing VM // that was created before the pricing was disabled let subscription_line_item_id = make_test_subscription(&db, user.id).await?; - let vm_id = db + let _vm_id = db .insert_vm(&Vm { id: 0, host_id: 1, diff --git a/lnvps_api_admin/src/admin/custom_pricing.rs b/lnvps_api_admin/src/admin/custom_pricing.rs index c382e85e..59ebc594 100644 --- a/lnvps_api_admin/src/admin/custom_pricing.rs +++ b/lnvps_api_admin/src/admin/custom_pricing.rs @@ -9,7 +9,8 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::Utc; use lnvps_api_common::{ - ApiData, ApiDiskInterface, ApiDiskType, ApiPaginatedData, ApiPaginatedResult, ApiResult, + ApiData, ApiDiskInterface, ApiDiskType, ApiError, ApiPaginatedData, ApiPaginatedResult, + ApiResult, }; use lnvps_db::{AdminAction, AdminResource, LNVpsDb, VmCustomPricing, VmCustomPricingDisk}; use serde::Deserialize; @@ -214,6 +215,8 @@ async fn admin_create_custom_pricing( cpu_limit: req.cpu_limit, }; + validate_ip_ranges(&pricing)?; + let pricing_id = this.db.insert_custom_pricing(&pricing).await?; // Insert disk pricing configurations @@ -339,6 +342,8 @@ async fn admin_update_custom_pricing( pricing.cpu_limit = v; } + validate_ip_ranges(&pricing)?; + this.db.update_custom_pricing(&pricing).await?; // Update disk pricing if provided @@ -480,3 +485,15 @@ async fn admin_copy_custom_pricing( let info = AdminCustomPricingInfo::from_custom_pricing(&this.db, &created_pricing).await?; ApiData::ok(info) } + +/// A plan whose minimum exceeds its maximum can never be ordered, so it is +/// rejected rather than stored as a silently unsellable offer. +fn validate_ip_ranges(pricing: &VmCustomPricing) -> Result<(), ApiError> { + if pricing.min_ip4 > pricing.max_ip4 { + return Err(ApiError::bad_request("min_ip4 cannot exceed max_ip4")); + } + if pricing.min_ip6 > pricing.max_ip6 { + return Err(ApiError::bad_request("min_ip6 cannot exceed max_ip6")); + } + Ok(()) +} diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index befc314f..c1e5b251 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -126,11 +126,11 @@ impl ApiVmTemplate { Self::from_standard_data(&template, &cost_plan, ®ion) } - pub async fn from_custom(db: &Arc, vm_id: u64, template_id: u64) -> Result { + pub async fn from_custom(db: &Arc, template_id: u64) -> Result { let template = db.get_custom_vm_template(template_id).await?; let pricing = db.get_custom_pricing(template.pricing_id).await?; let region = db.get_host_region(pricing.region_id).await?; - let price = PricingEngine::get_custom_vm_cost_amount(db, vm_id, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(db, &template).await?; Ok(Self { id: template.id, name: "Custom".to_string(), @@ -180,7 +180,7 @@ impl ApiVmTemplate { return Self::from_standard(db, t).await; } if let Some(t) = vm.custom_template_id { - return Self::from_custom(db, vm.id, t).await; + return Self::from_custom(db, t).await; } bail!("Invalid VM config, no template or custom template") } diff --git a/lnvps_api_common/src/network.rs b/lnvps_api_common/src/network.rs index 87e58978..ffbc6da9 100644 --- a/lnvps_api_common/src/network.rs +++ b/lnvps_api_common/src/network.rs @@ -112,12 +112,12 @@ impl NetworkProvisioner { } }; let want = if range_cidr.is_ipv4() { - ip4_count as usize - picked.ip4.len().min(ip4_count as usize) + (ip4_count as usize).saturating_sub(picked.ip4.len()) } else if matches!(range.allocation_mode, IpRangeAllocationMode::SlaacEui64) { // One MAC, one derived address. usize::from(picked.ip6.is_empty() && ip6_count > 0) } else { - ip6_count as usize - picked.ip6.len().min(ip6_count as usize) + (ip6_count as usize).saturating_sub(picked.ip6.len()) }; for _ in 0..want { diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 394374db..d49e7bbd 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -3,7 +3,6 @@ use crate::{ }; use anyhow::{Result, anyhow, bail, ensure}; use chrono::{DateTime, Days, Months, TimeDelta, Utc}; -use ipnetwork::IpNetwork; use isocountry::CountryCode; use lnvps_db::{ CpuArch, CpuFeature, CpuMfg, DiskInterface, DiskType, IntervalType, LNVpsDb, PaymentMethod, @@ -753,7 +752,6 @@ impl PricingEngine { /// Get the cost amount as (Currency,amount) pub async fn get_custom_vm_cost_amount( db: &Arc, - vm_id: u64, template: &VmCustomTemplate, ) -> Result { let pricing = db.get_custom_pricing(template.pricing_id).await?; @@ -885,7 +883,7 @@ impl PricingEngine { }; let template = self.db.get_custom_vm_template(template_id).await?; - let price = Self::get_custom_vm_cost_amount(&self.db, vm.id, &template).await?; + let price = Self::get_custom_vm_cost_amount(&self.db, &template).await?; // custom templates are always 1-month intervals; clamp base to now for expired VMs let base = self @@ -1397,7 +1395,7 @@ impl PricingEngine { ) } else if let Some(cid) = vm.custom_template_id { let template = self.db.get_custom_vm_template(cid).await?; - let price = Self::get_custom_vm_cost_amount(&self.db, vm.id, &template).await?; + let price = Self::get_custom_vm_cost_amount(&self.db, &template).await?; let time_value = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1); ( CurrencyAmount::from_u64(price.currency, price.total()), @@ -1555,8 +1553,7 @@ impl PricingEngine { Self::validate_custom_vm_spec(&self.db, &new_custom_template).await?; // Get the cost of renewal - let new_price = - Self::get_custom_vm_cost_amount(&self.db, vm_id, &new_custom_template).await?; + let new_price = Self::get_custom_vm_cost_amount(&self.db, &new_custom_template).await?; let new_price = CurrencyAmount::from_u64(new_price.currency, new_price.total()); // Get the time value for the custom template @@ -1937,7 +1934,7 @@ mod tests { let db: Arc = Arc::new(db); let template = db.get_custom_vm_template(1).await?; - let price = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&db, &template).await?; // All costs now in cents: // cpu_cost = 150 cents/CPU * 2 CPUs = 300 cents // memory_cost = 50 cents/GB * 2 GB = 100 cents @@ -1967,15 +1964,11 @@ mod tests { template.ip4_count = 3; template.ip6_count = 2; - let price = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&db, &template).await?; assert_eq!(150, price.ip4_cost); assert_eq!(10, price.ip6_cost); assert_eq!(960, price.total()); - // Priced the same before the VM exists (no assignments to count). - let quote = PricingEngine::get_custom_vm_cost_amount(&db, 0, &template).await?; - assert_eq!(price.total(), quote.total()); - Ok(()) } @@ -1988,7 +1981,7 @@ mod tests { let mut template = db.get_custom_vm_template(1).await?; template.ip4_count = 0; - let price = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&db, &template).await?; assert_eq!(0, price.ip4_cost); Ok(()) @@ -2037,7 +2030,7 @@ mod tests { } let db: Arc = Arc::new(db); let template = db.get_custom_vm_template(1).await?; - let price = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await?; + let price = PricingEngine::get_custom_vm_cost_amount(&db, &template).await?; // memory rounds up to 2 GB * 50 = 100 cents assert_eq!(100, price.memory_cost); // disk rounds up to 6 GB * 5 = 30 cents @@ -2062,7 +2055,7 @@ mod tests { assert!(res.is_err(), "cpu=0 must be rejected (below min_cpu)"); // But pricing itself must still succeed — grandfathered/out-of-range VMs // must remain renewable and migratable. - let priced = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await; + let priced = PricingEngine::get_custom_vm_cost_amount(&db, &template).await; assert!( priced.is_ok(), "pricing must succeed for out-of-range specs (renewal/backfill)" @@ -2086,7 +2079,7 @@ mod tests { } let db: Arc = Arc::new(db); let template = db.get_custom_vm_template(1).await?; - let res = PricingEngine::get_custom_vm_cost_amount(&db, 1, &template).await; + let res = PricingEngine::get_custom_vm_cost_amount(&db, &template).await; assert!( res.is_err(), "an interface with no pricing row must be rejected" diff --git a/lnvps_e2e/src/admin_api.rs b/lnvps_e2e/src/admin_api.rs index 23a5259a..7a57ed9c 100644 --- a/lnvps_e2e/src/admin_api.rs +++ b/lnvps_e2e/src/admin_api.rs @@ -2996,6 +2996,35 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); } + /// A plan whose minimum address count exceeds its maximum could never be + /// ordered, so creating one is refused. + #[tokio::test] + async fn test_admin_create_custom_pricing_rejects_inverted_ip_range() { + let client = setup().await; + let body = serde_json::json!({ + "name": "e2e-inverted-ip-range", + "enabled": true, + "region_id": 1, + "currency": "EUR", + "cpu_cost": 100, + "memory_cost": 50, + "ip4_cost": 200, + "ip6_cost": 0, + "min_cpu": 1, + "max_cpu": 4, + "min_memory": 1073741824_u64, + "max_memory": 8589934592_u64, + "min_ip4": 3, + "max_ip4": 1, + "disk_pricing": [] + }); + let resp = client + .post_auth("/api/admin/v1/custom_pricing", &body) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn test_admin_get_custom_pricing() { let client = setup().await; From 1fb876d36e3a9ccfda6794fa5cbaf5d6094d248a Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 30 Jul 2026 15:57:59 +0100 Subject: [PATCH 3/4] Cap offered IP counts at what the guest can be configured with --- ADMIN_API_ENDPOINTS.md | 26 ++++++---- API_CHANGELOG.md | 1 + lnvps_api_admin/src/admin/custom_pricing.rs | 4 +- lnvps_api_admin/src/admin/mod.rs | 20 +++++++- lnvps_api_admin/src/admin/vm_templates.rs | 13 +++-- lnvps_api_common/src/model.rs | 44 +++++++++++++++++ lnvps_e2e/src/admin_api.rs | 54 +++++++++++++++++++++ lnvps_e2e/src/lifecycle.rs | 31 +++++++----- 8 files changed, 168 insertions(+), 25 deletions(-) diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 92bfac48..b0cf2fbc 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -389,6 +389,10 @@ Body: // Optional - e.g. "x86_64", "arm64"; omitted means any "cpu_feature": ["string"], // Optional - required CPU features, e.g. ["AVX2"]; empty means any + "ip4_count": number, + // Optional - IPv4 addresses to assign, default 1; must be within the plan's range + "ip6_count": number, + // Optional - IPv6 addresses to assign, default 1; must be within the plan's range "image_id": number, // Required - OS image ID "ssh_key_id": number, @@ -1765,10 +1769,11 @@ Body: // optional - if not provided, cost plan will be auto-created "region_id": number, "ip4_count": number, - // optional - IPv4 addresses included in this offer, default 1 + // optional - IPv4 addresses included in this offer, default 1. Capped at 1 + // until the guest network config can carry more than one address per family "ip6_count": number, - // optional - IPv6 addresses included in this offer, default 1. Assignment is - // best-effort: a region with no IPv6 range still provisions + // optional - IPv6 addresses included in this offer, default 1, capped at 1. + // Assignment is best-effort: a region with no IPv6 range still provisions // Cost plan auto-creation fields (used when cost_plan_id not provided) "cost_plan_name": "string", @@ -2037,11 +2042,12 @@ Body: "min_ip4": number, // optional - Minimum IPv4 addresses selectable, default 1 "max_ip4": number, - // optional - Maximum IPv4 addresses selectable, default 1 + // optional - Maximum IPv4 addresses selectable, default 1. Capped at 1 until + // the guest network config can carry more than one address per family "min_ip6": number, // optional - Minimum IPv6 addresses selectable, default 1 "max_ip6": number, - // optional - Maximum IPv6 addresses selectable, default 1 + // optional - Maximum IPv6 addresses selectable, default 1, capped at 1 "disk_iops_read": number, // optional - Maximum disk read IOPS (omit or null = uncapped) "disk_iops_write": number, @@ -2114,11 +2120,12 @@ Body (all optional): "min_ip4": number, // optional - Minimum IPv4 addresses selectable, default 1 "max_ip4": number, - // optional - Maximum IPv4 addresses selectable, default 1 + // optional - Maximum IPv4 addresses selectable, default 1. Capped at 1 until + // the guest network config can carry more than one address per family "min_ip6": number, // optional - Minimum IPv6 addresses selectable, default 1 "max_ip6": number, - // optional - Maximum IPv6 addresses selectable, default 1 + // optional - Maximum IPv6 addresses selectable, default 1, capped at 1 "disk_iops_read": "number | null", // Maximum disk read IOPS - send null to clear "disk_iops_write": "number | null", @@ -4845,11 +4852,12 @@ The RBAC system uses the following permission format: `resource::action` "min_ip4": number, // optional - Minimum IPv4 addresses selectable, default 1 "max_ip4": number, - // optional - Maximum IPv4 addresses selectable, default 1 + // optional - Maximum IPv4 addresses selectable, default 1. Capped at 1 until + // the guest network config can carry more than one address per family "min_ip6": number, // optional - Minimum IPv6 addresses selectable, default 1 "max_ip6": number, - // optional - Maximum IPv6 addresses selectable, default 1 + // optional - Maximum IPv6 addresses selectable, default 1, capped at 1 "disk_iops_read": number, // Maximum disk read IOPS - omitted when uncapped "disk_iops_write": number, diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index cdf516e7..89765bdf 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Custom VM billing now takes the counts from the ordered spec rather than counting the VM's current assignments. Existing VMs are migrated to the counts they currently hold, so no live renewal amount moves. - Provisioning allocates the stated number of addresses across the region's ranges. IPv4 is all-or-nothing: an order asking for addresses the region cannot supply fails instead of quietly getting fewer. IPv6 remains best-effort, unchanged — a region without an IPv6 range still provisions, and a SLAAC range yields one address per VM. - Admin: `ip4_count`/`ip6_count` on VM template create/update/read, and `min_ip4`/`max_ip4`/`min_ip6`/`max_ip6` on custom pricing create/update/read/copy. All default to 1, so no plan starts offering a choice until an operator widens it. + - **The offered maximum is capped at 1 address per family for now** — a guest gets one cloud-init `ipconfig` entry, which carries a single IPv4 and a single IPv6, so a wider offer would allocate and bill for addresses that never reach the VM. `ip4_count`/`ip6_count` above 1 on a template, and `max_ip4`/`max_ip6` above 1 on a custom pricing plan, are refused with a `400`. The cap lifts once the guest-side network config can carry more than one address per family (issue #343). - **`host_ssh_keys` on VM status** (issue #154) — `GET /api/v1/vm/{id}` and `GET /api/v1/vm` now return the VM's own SSH host keys as `[{ key_type, public_key, fingerprint_sha256 }]`, so a client can verify the host on first connect instead of accepting whatever key answers. The list is empty until the keys are captured (scanned from the host once the VM is running, public keys only) and is re-captured after a reinstall, which regenerates them. Additive: no existing field changed. Not to be confused with `ssh_key`, which is the customer's authorized key. diff --git a/lnvps_api_admin/src/admin/custom_pricing.rs b/lnvps_api_admin/src/admin/custom_pricing.rs index 59ebc594..c8daaeed 100644 --- a/lnvps_api_admin/src/admin/custom_pricing.rs +++ b/lnvps_api_admin/src/admin/custom_pricing.rs @@ -1,9 +1,9 @@ -use crate::admin::RouterState; use crate::admin::auth::AdminAuth; use crate::admin::model::{ AdminCustomPricingDisk, AdminCustomPricingInfo, CopyCustomPricingRequest, CreateCustomPricingRequest, UpdateCustomPricingRequest, }; +use crate::admin::{RouterState, validate_offered_ip_count}; use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -495,5 +495,7 @@ fn validate_ip_ranges(pricing: &VmCustomPricing) -> Result<(), ApiError> { if pricing.min_ip6 > pricing.max_ip6 { return Err(ApiError::bad_request("min_ip6 cannot exceed max_ip6")); } + validate_offered_ip_count("max_ip4", pricing.max_ip4)?; + validate_offered_ip_count("max_ip6", pricing.max_ip6)?; Ok(()) } diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index c75a856b..9c19928c 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -1,6 +1,8 @@ use axum::Router; use axum::extract::FromRef; -use lnvps_api_common::{ExchangeRateService, RedisWorkFeedback, VmStateCache, WorkCommander}; +use lnvps_api_common::{ + ApiError, ExchangeRateService, RedisWorkFeedback, VmStateCache, WorkCommander, +}; use lnvps_db::LNVpsDb; use std::sync::Arc; @@ -34,6 +36,22 @@ mod vm_templates; mod vms; mod websocket; +/// Largest address count an offer may carry, per family. +/// +/// A guest gets one cloud-init `ipconfig` entry, which holds a single IPv4 and a +/// single IPv6, so an offer above this would allocate and bill for addresses that +/// never reach the VM. Raise once the guest-side config can carry more. +pub(crate) const MAX_OFFERED_IPS: u16 = 1; + +pub(crate) fn validate_offered_ip_count(field: &str, count: u16) -> Result<(), ApiError> { + if count > MAX_OFFERED_IPS { + return Err(ApiError::bad_request(format!( + "{field} cannot exceed {MAX_OFFERED_IPS}: the guest can only be configured with one address per family" + ))); + } + Ok(()) +} + #[derive(Clone, FromRef)] pub(crate) struct RouterState { pub db: Arc, diff --git a/lnvps_api_admin/src/admin/vm_templates.rs b/lnvps_api_admin/src/admin/vm_templates.rs index dc4ef9a3..bf1b4fca 100644 --- a/lnvps_api_admin/src/admin/vm_templates.rs +++ b/lnvps_api_admin/src/admin/vm_templates.rs @@ -1,8 +1,8 @@ -use crate::admin::RouterState; use crate::admin::auth::AdminAuth; use crate::admin::model::{ AdminCreateVmTemplateRequest, AdminUpdateVmTemplateRequest, AdminVmTemplateInfo, }; +use crate::admin::{RouterState, validate_offered_ip_count}; use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; @@ -138,6 +138,11 @@ async fn admin_create_vm_template( // Validate that region exists let _region = this.db.get_host_region(req.region_id).await?; + let ip4_count = req.ip4_count.unwrap_or(1); + let ip6_count = req.ip6_count.unwrap_or(1); + validate_offered_ip_count("ip4_count", ip4_count)?; + validate_offered_ip_count("ip6_count", ip6_count)?; + // Handle cost plan creation or validation let cost_plan_id = if let Some(existing_cost_plan_id) = req.cost_plan_id { // Validate that the provided cost plan exists @@ -201,8 +206,8 @@ async fn admin_create_vm_template( disk_interface: req.disk_interface.into(), cost_plan_id, region_id: req.region_id, - ip4_count: req.ip4_count.unwrap_or(1), - ip6_count: req.ip6_count.unwrap_or(1), + ip4_count, + ip6_count, disk_iops_read: req.disk_iops_read, disk_iops_write: req.disk_iops_write, disk_mbps_read: req.disk_mbps_read, @@ -324,9 +329,11 @@ async fn admin_update_vm_template( template.region_id = region_id; } if let Some(v) = req.ip4_count { + validate_offered_ip_count("ip4_count", v)?; template.ip4_count = v; } if let Some(v) = req.ip6_count { + validate_offered_ip_count("ip6_count", v)?; template.ip6_count = v; } if let Some(v) = req.disk_iops_read { diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index c1e5b251..0f173b32 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -790,6 +790,17 @@ pub struct CustomVmSpec { pub cpu_arch: Option, #[serde(default)] pub cpu_feature: Vec, + /// IPv4 addresses to assign; defaults to 1, the count every order implied + /// before this was selectable. + #[serde(default = "default_ip_count")] + pub ip4_count: u16, + /// IPv6 addresses to assign; defaults to 1. + #[serde(default = "default_ip_count")] + pub ip6_count: u16, +} + +fn default_ip_count() -> u16 { + 1 } impl CustomVmSpec { @@ -824,6 +835,8 @@ impl CustomVmSpec { None => CpuArch::default(), }, cpu_features: cpu_features.into(), + ip4_count: self.ip4_count, + ip6_count: self.ip6_count, ..Default::default() }) } @@ -1043,9 +1056,40 @@ mod tests { cpu_mfg: None, cpu_arch: None, cpu_feature: vec![], + ip4_count: 1, + ip6_count: 1, } } + /// An omitted count means the single address every order implied before the + /// counts were selectable, not zero. + #[test] + fn test_custom_vm_spec_defaults_ip_counts_to_one() { + let spec: CustomVmSpec = serde_json::from_str( + r#"{"pricing_id":7,"cpu":4,"memory":1073741824,"disk":10737418240, + "disk_type":"ssd","disk_interface":"pcie"}"#, + ) + .unwrap(); + assert_eq!(1, spec.ip4_count); + assert_eq!(1, spec.ip6_count); + + let t = spec.to_template().unwrap(); + assert_eq!(1, t.ip4_count); + assert_eq!(1, t.ip6_count); + } + + #[test] + fn test_custom_vm_spec_carries_ip_counts() { + let spec = CustomVmSpec { + ip4_count: 2, + ip6_count: 0, + ..custom_spec() + }; + let t = spec.to_template().unwrap(); + assert_eq!(2, t.ip4_count); + assert_eq!(0, t.ip6_count); + } + #[test] fn test_custom_vm_spec_to_template() { let spec = CustomVmSpec { diff --git a/lnvps_e2e/src/admin_api.rs b/lnvps_e2e/src/admin_api.rs index 7a57ed9c..9d3e3cca 100644 --- a/lnvps_e2e/src/admin_api.rs +++ b/lnvps_e2e/src/admin_api.rs @@ -3025,6 +3025,60 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + /// The guest can only be configured with one address per family, so an offer + /// above that would allocate and bill for addresses the VM never receives. + #[tokio::test] + async fn test_admin_create_custom_pricing_rejects_unconfigurable_ip_count() { + let client = setup().await; + let body = serde_json::json!({ + "name": "e2e-too-many-ips", + "enabled": true, + "region_id": 1, + "currency": "EUR", + "cpu_cost": 100, + "memory_cost": 50, + "ip4_cost": 200, + "ip6_cost": 0, + "min_cpu": 1, + "max_cpu": 4, + "min_memory": 1073741824_u64, + "max_memory": 8589934592_u64, + "min_ip4": 1, + "max_ip4": 4, + "disk_pricing": [] + }); + let resp = client + .post_auth("/api/admin/v1/custom_pricing", &body) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_admin_create_vm_template_rejects_unconfigurable_ip_count() { + let client = setup().await; + let body = serde_json::json!({ + "name": "e2e-too-many-ips-template", + "enabled": true, + "region_id": 1, + "cpu": 1, + "memory": 1073741824_u64, + "disk_size": 10737418240_u64, + "disk_type": "ssd", + "disk_interface": "pcie", + "ip4_count": 2, + "cost_plan_amount": 500, + "cost_plan_currency": "EUR", + "cost_plan_interval_amount": 1, + "cost_plan_interval_type": "month" + }); + let resp = client + .post_auth("/api/admin/v1/vm_templates", &body) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn test_admin_get_custom_pricing() { let client = setup().await; diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index d1a7fc11..dec9a36f 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -221,7 +221,7 @@ mod tests { "min_memory": 1073741824_u64, "max_memory": 17179869184_u64, "min_ip4": 1, - "max_ip4": 2, + "max_ip4": 1, "min_ip6": 0, "max_ip6": 1, "disk_pricing": [{ @@ -1743,7 +1743,7 @@ mod tests { "disk": 10737418240_u64, "disk_type": "ssd", "disk_interface": "pcie", - "ip4_count": 2, + "ip4_count": 1, "ip6_count": 0, "image_id": image_id, "ssh_key_id": ssh_key_id @@ -1758,21 +1758,15 @@ mod tests { let id = vm["data"]["id"].as_u64().unwrap(); multi_ip_vm_id = Some(id); assert_eq!( - 2, + 1, vm["data"]["template"]["ip4_count"].as_u64().unwrap(), "ordered IPv4 count must be reflected on the VM's template" ); assert_eq!(0, vm["data"]["template"]["ip6_count"].as_u64().unwrap()); - // Two addresses at 200 cents each, so the IPv4 share alone exceeds - // the single-address price this plan used to imply. - let amount = vm["data"]["template"]["cost_plan"]["amount"] - .as_u64() - .unwrap(); - assert!(amount >= 400, "2x IPv4 must be billed, got {amount}"); - eprintln!("Ordered custom VM {id} with 2x IPv4 (amount {amount})"); + eprintln!("Ordered custom VM {id} with an explicit IPv4 count"); } else { eprintln!( - "Multi-IP custom VM order returned {} (expected if provisioner unavailable)", + "Explicit-IP custom VM order returned {} (expected if provisioner unavailable)", resp.status() ); } @@ -1790,6 +1784,21 @@ mod tests { "an out-of-range IPv4 count must not be orderable" ); + // A plan cannot offer more addresses than the guest can be configured + // with, so widening it is refused. + let resp = admin + .patch_auth( + &format!("/api/admin/v1/custom_pricing/{custom_pricing_id}"), + &serde_json::json!({ "max_ip4": 2 }), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "max_ip4 above the configurable limit must be refused" + ); + // ---------------------------------------------------------------- // 20. Cleanup: hard-delete VMs and all infrastructure via DB // The worker cannot reach fake hosts, so API-level VM deletion From 3bd87c72171e9cc90e005c2fdeaaee644c53dc9c Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 30 Jul 2026 16:04:14 +0100 Subject: [PATCH 4/4] Bound orderable IP counts, not just the plan's range --- API_CHANGELOG.md | 2 +- lnvps_api_admin/src/admin/mod.rs | 17 +++++----- lnvps_api_common/src/model.rs | 7 +++++ lnvps_api_common/src/pricing.rs | 53 +++++++++++++++++++++++++++++++- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 89765bdf..cc6611a9 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -35,7 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Custom VM billing now takes the counts from the ordered spec rather than counting the VM's current assignments. Existing VMs are migrated to the counts they currently hold, so no live renewal amount moves. - Provisioning allocates the stated number of addresses across the region's ranges. IPv4 is all-or-nothing: an order asking for addresses the region cannot supply fails instead of quietly getting fewer. IPv6 remains best-effort, unchanged — a region without an IPv6 range still provisions, and a SLAAC range yields one address per VM. - Admin: `ip4_count`/`ip6_count` on VM template create/update/read, and `min_ip4`/`max_ip4`/`min_ip6`/`max_ip6` on custom pricing create/update/read/copy. All default to 1, so no plan starts offering a choice until an operator widens it. - - **The offered maximum is capped at 1 address per family for now** — a guest gets one cloud-init `ipconfig` entry, which carries a single IPv4 and a single IPv6, so a wider offer would allocate and bill for addresses that never reach the VM. `ip4_count`/`ip6_count` above 1 on a template, and `max_ip4`/`max_ip6` above 1 on a custom pricing plan, are refused with a `400`. The cap lifts once the guest-side network config can carry more than one address per family (issue #343). + - **The offered maximum is capped at 1 address per family for now** — a guest gets one cloud-init `ipconfig` entry, which carries a single IPv4 and a single IPv6, so a wider offer would allocate and bill for addresses that never reach the VM. `ip4_count`/`ip6_count` above 1 on a template, and `max_ip4`/`max_ip6` above 1 on a custom pricing plan, are refused with a `400`, and an order for more than 1 is refused even if a stored plan's range would allow it. The cap lifts once the guest-side network config can carry more than one address per family (issue #343). - **`host_ssh_keys` on VM status** (issue #154) — `GET /api/v1/vm/{id}` and `GET /api/v1/vm` now return the VM's own SSH host keys as `[{ key_type, public_key, fingerprint_sha256 }]`, so a client can verify the host on first connect instead of accepting whatever key answers. The list is empty until the keys are captured (scanned from the host once the VM is running, public keys only) and is re-captured after a reinstall, which regenerates them. Additive: no existing field changed. Not to be confused with `ssh_key`, which is the customer's authorized key. diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index 9c19928c..569725ba 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -1,7 +1,8 @@ use axum::Router; use axum::extract::FromRef; use lnvps_api_common::{ - ApiError, ExchangeRateService, RedisWorkFeedback, VmStateCache, WorkCommander, + ApiError, ExchangeRateService, MAX_CONFIGURABLE_IPS, RedisWorkFeedback, VmStateCache, + WorkCommander, }; use lnvps_db::LNVpsDb; use std::sync::Arc; @@ -36,17 +37,13 @@ mod vm_templates; mod vms; mod websocket; -/// Largest address count an offer may carry, per family. -/// -/// A guest gets one cloud-init `ipconfig` entry, which holds a single IPv4 and a -/// single IPv6, so an offer above this would allocate and bill for addresses that -/// never reach the VM. Raise once the guest-side config can carry more. -pub(crate) const MAX_OFFERED_IPS: u16 = 1; - +/// Refuse an offer wider than what a guest can actually be configured with, so an +/// operator cannot create a plan whose orders would allocate and bill addresses +/// the VM never receives. The order path enforces the same bound. pub(crate) fn validate_offered_ip_count(field: &str, count: u16) -> Result<(), ApiError> { - if count > MAX_OFFERED_IPS { + if count > MAX_CONFIGURABLE_IPS { return Err(ApiError::bad_request(format!( - "{field} cannot exceed {MAX_OFFERED_IPS}: the guest can only be configured with one address per family" + "{field} cannot exceed {MAX_CONFIGURABLE_IPS}: a VM can only be configured with that many addresses per family" ))); } Ok(()) diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index 0f173b32..204c7016 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -803,6 +803,13 @@ fn default_ip_count() -> u16 { 1 } +/// Largest address count a VM can actually be given, per family. +/// +/// A guest gets one cloud-init `ipconfig` entry, which holds a single IPv4 and a +/// single IPv6, so anything above this would be allocated and billed without ever +/// reaching the VM. Raise once the guest-side config can carry more. +pub const MAX_CONFIGURABLE_IPS: u16 = 1; + impl CustomVmSpec { /// Build the template this spec describes. /// diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index d49e7bbd..788ae2bf 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -1,5 +1,6 @@ use crate::{ - ConvertedCurrencyAmount, ExchangeRateService, Ticker, TickerRate, UpgradeConfig, VatClient, + ConvertedCurrencyAmount, ExchangeRateService, MAX_CONFIGURABLE_IPS, Ticker, TickerRate, + UpgradeConfig, VatClient, }; use anyhow::{Result, anyhow, bail, ensure}; use chrono::{DateTime, Days, Months, TimeDelta, Utc}; @@ -866,6 +867,23 @@ impl PricingEngine { pricing.max_ip6 ); } + // The plan's own bounds are not enough: a stored plan can exceed what a + // guest can be configured with, and an order against it would allocate + // and bill addresses the VM never receives. + if template.ip4_count > MAX_CONFIGURABLE_IPS { + bail!( + "IPv4 count {} exceeds the {} address(es) a VM can be configured with", + template.ip4_count, + MAX_CONFIGURABLE_IPS + ); + } + if template.ip6_count > MAX_CONFIGURABLE_IPS { + bail!( + "IPv6 count {} exceeds the {} address(es) a VM can be configured with", + template.ip6_count, + MAX_CONFIGURABLE_IPS + ); + } Ok(()) } @@ -2014,6 +2032,39 @@ mod tests { Ok(()) } + /// A stored plan can be wider than what a guest can be configured with — the + /// migration adopts counts from existing VMs — so the order path must bound + /// the count itself and not trust the plan's range. + #[tokio::test] + async fn validate_custom_vm_spec_refuses_unconfigurable_ip_counts() -> Result<()> { + let db = MockDb::default(); + add_custom_pricing(&db).await; + let db: Arc = Arc::new(db); + + let base = db.get_custom_vm_template(1).await?; + let pricing = db.get_custom_pricing(base.pricing_id).await?; + assert!( + pricing.max_ip4 > MAX_CONFIGURABLE_IPS && pricing.max_ip6 > MAX_CONFIGURABLE_IPS, + "plan must be wider than the cap for this to test anything" + ); + + let mut within_plan_v4 = base.clone(); + within_plan_v4.ip4_count = MAX_CONFIGURABLE_IPS + 1; + let err = PricingEngine::validate_custom_vm_spec(&db, &within_plan_v4) + .await + .expect_err("within the plan but above what a guest can hold"); + assert!(err.to_string().contains("can be configured with"), "{err}"); + + let mut within_plan_v6 = base.clone(); + within_plan_v6.ip6_count = MAX_CONFIGURABLE_IPS + 1; + let err = PricingEngine::validate_custom_vm_spec(&db, &within_plan_v6) + .await + .expect_err("within the plan but above what a guest can hold"); + assert!(err.to_string().contains("can be configured with"), "{err}"); + + Ok(()) + } + /// Regression: sub-GB memory/disk must be billed (rounded up), not truncated /// to 0. Previously `memory / GB` floored a request just under 2 GB to 1 GB /// (and anything under 1 GB to free).