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
2 changes: 2 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Custom VM price states its billing period** (issue #302) — `POST /api/v1/vm/custom-template/price` gains `interval_amount` and `interval_type`, the same shape a standard template's cost plan already uses. Custom builds renew monthly, so the values are always `1` and `"month"`; the point is that a client no longer has to hard-code the period to render a recurring price. Additive: no existing field changed.

- **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.

- **A VM can hold more than one address per family** (issue #343) — every assignment is now placed on the VM's single NIC through a per-VM cloud-init network config (`cicustom network=`), instead of the built-in `ipconfig`, which carries only one IPv4 and one IPv6 per interface. A VM with one address per family is unaffected and keeps its existing `ipconfig`, so nothing already deployed is reconfigured. The interface is matched by MAC so the config does not depend on guest device naming; one default route per family is emitted (the first assignment's gateway wins, as before); a SLAAC range contributes router advertisement rather than a pinned address. No response shape changed — the offered maximum is now simply whatever an operator sets on the plan or template.
Expand Down
6 changes: 6 additions & 0 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ pub struct ApiCustomVmPrice {
/// currencies. Use `GET /api/v1/exchange-rate` instead. Still populated for
/// backward compatibility; will be removed in a future release.
pub other_price: Vec<ApiPrice>,
/// Billing period the amount buys, same shape as a standard template's cost
/// plan. Custom builds renew monthly, so this is always 1 month.
pub interval_amount: u64,
pub interval_type: ApiIntervalType,
}

impl ApiCustomVmPrice {
Expand All @@ -80,6 +84,8 @@ impl ApiCustomVmPrice {
currency: amount.currency().into(),
amount: amount.value(),
other_price,
interval_amount: CUSTOM_VM_INTERVAL_AMOUNT,
interval_type: CUSTOM_VM_INTERVAL_TYPE.into(),
})
}
}
Expand Down
22 changes: 18 additions & 4 deletions lnvps_api_common/src/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ pub struct RemainingTimeInfo {
pub prorated_cost: CurrencyAmount,
}

/// Billing period a custom VM price buys. Custom builds have no cost plan row,
/// so the interval is fixed here and is what both the renewal and the quoted
/// price report.
pub const CUSTOM_VM_INTERVAL_AMOUNT: u64 = 1;
pub const CUSTOM_VM_INTERVAL_TYPE: IntervalType = IntervalType::Month;
// The renewal below advances by months, so a change of unit here has to be made
// there too.
const _: () = assert!(matches!(CUSTOM_VM_INTERVAL_TYPE, IntervalType::Month));

/// ISO 3166-1 alpha-3 codes treated as inside the EU VAT area (27 member states).
const EU_VAT_COUNTRIES: [&str; 27] = [
"AUT", "BEL", "BGR", "HRV", "CYP", "CZE", "DNK", "EST", "FIN", "FRA", "DEU", "GRC", "HUN",
Expand Down Expand Up @@ -885,13 +894,14 @@ impl PricingEngine {
let template = self.db.get_custom_vm_template(template_id).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
// clamp base to now for expired VMs
let base = self
.vm_subscription_expires(vm)
.await
.unwrap_or_else(Utc::now)
.max(Utc::now());
let time_value = (base.add(Months::new(1)) - base).num_seconds() as u64;
let time_value =
(base.add(Months::new(CUSTOM_VM_INTERVAL_AMOUNT as u32)) - base).num_seconds() as u64;
let converted_amount = self
.get_amount_and_rate(
CurrencyAmount::from_u64(price.currency, price.total()),
Expand Down Expand Up @@ -1396,7 +1406,10 @@ 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, &template).await?;
let time_value = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1);
let time_value = Self::cost_plan_interval_to_seconds(
CUSTOM_VM_INTERVAL_TYPE,
CUSTOM_VM_INTERVAL_AMOUNT,
);
(
CurrencyAmount::from_u64(price.currency, price.total()),
time_value,
Expand Down Expand Up @@ -1557,7 +1570,8 @@ impl PricingEngine {
let new_price = CurrencyAmount::from_u64(new_price.currency, new_price.total());

// Get the time value for the custom template
let custom_plan_seconds = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1);
let custom_plan_seconds =
Self::cost_plan_interval_to_seconds(CUSTOM_VM_INTERVAL_TYPE, CUSTOM_VM_INTERVAL_AMOUNT);
let new_cost_per_second = new_price.value() as f64 / custom_plan_seconds as f64;

// calculate the cost based on the time until the vm expires
Expand Down
7 changes: 7 additions & 0 deletions lnvps_e2e/src/user_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ mod tests {
"Custom template price calc should return 200, 400, or 500, got: {}",
resp.status()
);
if resp.status() == StatusCode::OK {
// A recurring price with no period reads as a total, so the
// interval has to be on the wire.
let price: ApiData<Value> = parse_data(resp).await.unwrap();
assert_eq!(price.data["interval_amount"], 1);
assert_eq!(price.data["interval_type"], "month");
}
}
}
}
Expand Down
Loading