Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1764,6 +1768,13 @@ 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. 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, 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",
// optional, defaults to "{template_name} Cost Plan"
Expand Down Expand Up @@ -1822,6 +1833,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,
Expand Down Expand Up @@ -2026,6 +2039,15 @@ 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. 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, capped at 1
"disk_iops_read": number,
// optional - Maximum disk read IOPS (omit or null = uncapped)
"disk_iops_write": number,
Expand Down Expand Up @@ -2095,6 +2117,15 @@ 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. 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, capped at 1
"disk_iops_read": "number | null",
// Maximum disk read IOPS - send null to clear
"disk_iops_write": "number | null",
Expand Down Expand Up @@ -4759,6 +4790,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",
Expand Down Expand Up @@ -4816,6 +4849,15 @@ 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. 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, capped at 1
"disk_iops_read": number,
// Maximum disk read IOPS - omitted when uncapped
"disk_iops_write": number,
Expand Down
9 changes: 9 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ 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.
- **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.

### Added
Expand Down
43 changes: 43 additions & 0 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,17 @@ pub struct ApiCustomVmRequest {
/// CPU features as strings (e.g. "AVX2", "AES", "VMX")
#[serde(default)]
pub cpu_feature: Vec<String>,
/// 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<ApiCustomVmRequest> for VmCustomTemplate {
Expand All @@ -777,6 +788,8 @@ impl From<ApiCustomVmRequest> 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())
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
14 changes: 14 additions & 0 deletions lnvps_api/src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn LNVpsDb>) -> Result<VmResources> {
let vm = db.get_vm(vm_id).await?;
if let Some(t) = vm.template_id {
Expand Down
Loading
Loading