From 8c3756e3e8f35b04fbfeb6926f07883f9695d632 Mon Sep 17 00:00:00 2001 From: kieran Date: Thu, 6 Mar 2025 21:42:27 +0000 Subject: [PATCH] feat: custom pricing closes #3 --- lnvps_db/Cargo.lock | 160 +--------- .../20250306113236_custom_pricing.sql | 49 +++ lnvps_db/src/lib.rs | 15 + lnvps_db/src/model.rs | 56 +++- lnvps_db/src/mysql.rs | 74 ++++- src/api/model.rs | 172 ++++++++-- src/api/routes.rs | 133 +++++++- src/exchange.rs | 3 +- src/host/mod.rs | 46 ++- src/host/proxmox.rs | 7 +- src/mocks.rs | 225 ++++++++++--- src/provisioner/capacity.rs | 136 ++++++-- src/provisioner/lnvps.rs | 171 ++++++---- src/provisioner/mod.rs | 55 ++++ src/provisioner/pricing.rs | 299 ++++++++++++++++++ 15 files changed, 1242 insertions(+), 359 deletions(-) create mode 100644 lnvps_db/migrations/20250306113236_custom_pricing.sql create mode 100644 src/provisioner/pricing.rs diff --git a/lnvps_db/Cargo.lock b/lnvps_db/Cargo.lock index 9d814f2..bf2dec9 100644 --- a/lnvps_db/Cargo.lock +++ b/lnvps_db/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -245,41 +245,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "darling" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "der" version = "0.7.9" @@ -291,16 +256,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", - "serde", -] - [[package]] name = "digest" version = "0.10.7" @@ -394,12 +349,6 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -508,12 +457,6 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.14.5" @@ -725,12 +668,6 @@ dependencies = [ "syn", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.0.3" @@ -752,17 +689,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.6.0" @@ -771,7 +697,6 @@ checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown 0.15.1", - "serde", ] [[package]] @@ -840,8 +765,6 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "serde", - "serde_with", "sqlx", "url", ] @@ -932,12 +855,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-integer" version = "0.1.46" @@ -1072,12 +989,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1239,36 +1150,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" -dependencies = [ - "base64", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.6.0", - "serde", - "serde_derive", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" version = "0.10.6" @@ -1399,7 +1280,7 @@ dependencies = [ "hashbrown 0.14.5", "hashlink", "hex", - "indexmap 2.6.0", + "indexmap", "log", "memchr", "once_cell", @@ -1579,12 +1460,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -1646,37 +1521,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" - -[[package]] -name = "time-macros" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.7.6" diff --git a/lnvps_db/migrations/20250306113236_custom_pricing.sql b/lnvps_db/migrations/20250306113236_custom_pricing.sql new file mode 100644 index 0000000..a491bd8 --- /dev/null +++ b/lnvps_db/migrations/20250306113236_custom_pricing.sql @@ -0,0 +1,49 @@ +-- fix this fk ref +ALTER TABLE vm_template DROP FOREIGN KEY fk_template_region; +alter table vm_template + add constraint fk_template_region foreign key (region_id) references vm_host_region (id); + +create table vm_custom_pricing +( + id integer unsigned not null auto_increment primary key, + name varchar(100) not null, + enabled bit(1) not null, + created timestamp default current_timestamp, + expires timestamp, + region_id integer unsigned not null, + currency varchar(5) not null, + cpu_cost float not null, + memory_cost float not null, + ip4_cost float not null, + ip6_cost float not null, + + constraint fk_custom_pricing_region foreign key (region_id) references vm_host_region (id) +); +create table vm_custom_pricing_disk +( + id integer unsigned not null auto_increment primary key, + pricing_id integer unsigned not null, + kind smallint unsigned not null, + interface smallint unsigned not null, + cost float not null, + + constraint fk_custom_pricing_disk foreign key (pricing_id) references vm_custom_pricing (id) +); +ALTER TABLE vm MODIFY COLUMN template_id int (10) unsigned NULL; +ALTER TABLE vm + add COLUMN custom_template_id int(10) unsigned NULL; + +create table vm_custom_template +( + id integer unsigned not null auto_increment primary key, + cpu tinyint unsigned not null, + memory bigint unsigned not null, + disk_size bigint unsigned not null, + disk_type smallint unsigned not null, + disk_interface smallint unsigned not null, + pricing_id integer unsigned not null, + + constraint fk_custom_template_pricing foreign key (pricing_id) references vm_custom_pricing (id) +); +alter table vm + add constraint fk_vm_custom_template foreign key (custom_template_id) references vm_custom_template (id); diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 1008d8d..b024111 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -139,4 +139,19 @@ pub trait LNVpsDb: Sync + Send { /// Return the most recently settled invoice async fn last_paid_invoice(&self) -> Result>; + + /// Return the list of active custom pricing models for a given region + async fn list_custom_pricing(&self, region_id: u64) -> Result>; + + /// Get a custom pricing model + async fn get_custom_pricing(&self, id: u64) -> Result; + + /// Get a custom pricing model + async fn get_custom_vm_template(&self, id: u64) -> Result; + + /// Insert custom vm template + async fn insert_custom_vm_template(&self, template: &VmCustomTemplate) -> Result; + + /// Return the list of disk prices for a given custom pricing model + async fn list_custom_pricing_disk(&self, pricing_id: u64) -> Result>; } diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index b2642a7..80d3640 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -92,7 +92,7 @@ pub struct VmHostDisk { pub enabled: bool, } -#[derive(Clone, Debug, sqlx::Type, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, sqlx::Type, Default, PartialEq, Eq)] #[repr(u16)] pub enum DiskType { #[default] @@ -100,7 +100,7 @@ pub enum DiskType { SSD = 1, } -#[derive(Clone, Debug, sqlx::Type, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, sqlx::Type, Default, PartialEq, Eq)] #[repr(u16)] pub enum DiskInterface { #[default] @@ -109,7 +109,7 @@ pub enum DiskInterface { PCIe = 2, } -#[derive(Clone, Debug, sqlx::Type, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, sqlx::Type, Default, PartialEq, Eq)] #[repr(u16)] pub enum OsDistribution { #[default] @@ -203,6 +203,50 @@ pub struct VmTemplate { pub region_id: u64, } +/// A custom pricing template, used for billing calculation of a specific VM +/// This mostly just stores the number of resources assigned and the specific pricing used +#[derive(FromRow, Clone, Debug, Default)] +pub struct VmCustomTemplate { + pub id: u64, + pub cpu: u16, + pub memory: u64, + pub disk_size: u64, + pub disk_type: DiskType, + pub disk_interface: DiskInterface, + pub pricing_id: u64, +} + +/// Custom pricing template, usually 1 per region +#[derive(FromRow, Clone, Debug, Default)] +pub struct VmCustomPricing { + pub id: u64, + pub name: String, + pub enabled: bool, + pub created: DateTime, + pub expires: Option>, + pub region_id: u64, + pub currency: String, + /// Cost per CPU core + pub cpu_cost: f32, + /// Cost per GB ram + pub memory_cost: f32, + /// Cost per IPv4 address + pub ip4_cost: f32, + /// Cost per IPv6 address + pub ip6_cost: f32, +} + +/// Pricing per GB on a disk type (SSD/HDD) +#[derive(FromRow, Clone, Debug, Default)] +pub struct VmCustomPricingDisk { + pub id: u64, + pub pricing_id: u64, + pub kind: DiskType, + pub interface: DiskInterface, + /// Cost as per the currency of the [VmCustomPricing::currency] + pub cost: f32, +} + #[derive(FromRow, Clone, Debug, Default)] pub struct Vm { /// Unique VM ID (Same in proxmox) @@ -213,8 +257,10 @@ pub struct Vm { pub user_id: u64, /// The base image of this VM pub image_id: u64, - /// The base image of this VM - pub template_id: u64, + /// The base image of this VM [VmTemplate] + pub template_id: Option, + /// Custom pricing specification used for this vm [VmCustomTemplate] + pub custom_template_id: Option, /// Users ssh-key assigned to this VM pub ssh_key_id: u64, /// When the VM was created diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 089028b..376125b 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,6 +1,7 @@ use crate::{ - IpRange, LNVpsDb, User, UserSshKey, Vm, VmCostPlan, VmHost, VmHostDisk, VmHostRegion, - VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + IpRange, LNVpsDb, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, + VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, + VmTemplate, }; use anyhow::{bail, Error, Result}; use async_trait::async_trait; @@ -27,7 +28,9 @@ impl LNVpsDbMysql { #[async_trait] impl LNVpsDb for LNVpsDbMysql { async fn migrate(&self) -> Result<()> { - sqlx::migrate!().run(&self.db).await.map_err(Error::new) + let migrator = sqlx::migrate!(); + migrator.run(&self.db).await.map_err(Error::new)?; + Ok(()) } async fn upsert_user(&self, pubkey: &[u8; 32]) -> Result { @@ -210,7 +213,7 @@ impl LNVpsDb for LNVpsDbMysql { } async fn list_vm_templates(&self) -> Result> { - sqlx::query_as("select * from vm_template") + sqlx::query_as("select * from vm_template where enabled = 1") .fetch_all(&self.db) .await .map_err(Error::new) @@ -219,14 +222,14 @@ impl LNVpsDb for LNVpsDbMysql { async fn insert_vm_template(&self, template: &VmTemplate) -> Result { Ok(sqlx::query("insert into vm_template(name,enabled,created,expires,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) values(?,?,?,?,?,?,?,?,?,?,?) returning id") .bind(&template.name) - .bind(&template.enabled) - .bind(&template.created) - .bind(&template.expires) + .bind(template.enabled) + .bind(template.created) + .bind(template.expires) .bind(template.cpu) .bind(template.memory) .bind(template.disk_size) - .bind(&template.disk_type) - .bind(&template.disk_interface) + .bind(template.disk_type) + .bind(template.disk_interface) .bind(template.cost_plan_id) .bind(template.region_id) .fetch_one(&self.db) @@ -274,11 +277,12 @@ impl LNVpsDb for LNVpsDbMysql { } async fn insert_vm(&self, vm: &Vm) -> Result { - Ok(sqlx::query("insert into vm(host_id,user_id,image_id,template_id,ssh_key_id,created,expires,disk_id,mac_address,ref_code) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id") + Ok(sqlx::query("insert into vm(host_id,user_id,image_id,template_id,custom_template_id,ssh_key_id,created,expires,disk_id,mac_address,ref_code) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?,?) returning id") .bind(vm.host_id) .bind(vm.user_id) .bind(vm.image_id) .bind(vm.template_id) + .bind(vm.custom_template_id) .bind(vm.ssh_key_id) .bind(vm.created) .bind(vm.expires) @@ -343,7 +347,7 @@ impl LNVpsDb for LNVpsDbMysql { .bind(&ip_assignment.dns_forward_ref) .bind(&ip_assignment.dns_reverse) .bind(&ip_assignment.dns_reverse_ref) - .bind(&ip_assignment.id) + .bind(ip_assignment.id) .execute(&self.db) .await .map_err(Error::new)?; @@ -368,7 +372,7 @@ impl LNVpsDb for LNVpsDbMysql { async fn delete_vm_ip_assignment(&self, vm_id: u64) -> Result<()> { sqlx::query("update vm_ip_assignment set deleted = 1 where vm_id = ?") - .bind(&vm_id) + .bind(vm_id) .execute(&self.db) .await?; Ok(()) @@ -448,4 +452,50 @@ impl LNVpsDb for LNVpsDbMysql { .await .map_err(Error::new) } + + async fn list_custom_pricing(&self, region_id: u64) -> Result> { + sqlx::query_as("select * from vm_custom_pricing where region_id = ? and enabled = 1") + .bind(region_id) + .fetch_all(&self.db) + .await + .map_err(Error::new) + } + + async fn get_custom_pricing(&self, id: u64) -> Result { + sqlx::query_as("select * from vm_custom_pricing where id=?") + .bind(id) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } + + async fn get_custom_vm_template(&self, id: u64) -> Result { + sqlx::query_as("select * from vm_custom_template where id=?") + .bind(id) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } + + async fn insert_custom_vm_template(&self, template: &VmCustomTemplate) -> Result { + Ok(sqlx::query("insert into vm_custom_template(cpu,memory,disk_size,disk_type,disk_interface,pricing_id) 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) + .fetch_one(&self.db) + .await + .map_err(Error::new)? + .try_get(0)?) + } + + async fn list_custom_pricing_disk(&self, pricing_id: u64) -> Result> { + sqlx::query_as("select * from vm_custom_pricing_disk where pricing_id=?") + .bind(pricing_id) + .fetch_all(&self.db) + .await + .map_err(Error::new) + } } diff --git a/src/api/model.rs b/src/api/model.rs index f9706ae..907b680 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -1,11 +1,15 @@ +use crate::exchange::Currency; +use crate::provisioner::PricingEngine; use crate::status::VmState; +use anyhow::{bail, Result}; use chrono::{DateTime, Utc}; use ipnetwork::IpNetwork; -use lnvps_db::VmHostRegion; +use lnvps_db::{LNVpsDb, Vm, VmCostPlan, VmCustomTemplate, VmHost, VmHostRegion, VmTemplate}; use nostr::util::hex; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::str::FromStr; +use std::sync::Arc; #[derive(Serialize, Deserialize, JsonSchema)] pub struct ApiVmStatus { @@ -72,7 +76,7 @@ impl ApiVmIpAssignment { } } -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum DiskType { HDD = 0, @@ -88,7 +92,16 @@ impl From for DiskType { } } -#[derive(Serialize, Deserialize, JsonSchema)] +impl Into for DiskType { + fn into(self) -> lnvps_db::DiskType { + match self { + DiskType::HDD => lnvps_db::DiskType::HDD, + DiskType::SSD => lnvps_db::DiskType::SSD, + } + } +} + +#[derive(Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum DiskInterface { SATA = 0, @@ -106,6 +119,82 @@ impl From for DiskInterface { } } +impl From for lnvps_db::DiskInterface { + fn from(value: DiskInterface) -> Self { + match value { + DiskInterface::SATA => Self::SATA, + DiskInterface::SCSI => Self::SCSI, + DiskInterface::PCIe => Self::PCIe, + } + } +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct ApiTemplatesResponse { + pub templates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_template: Option>, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct ApiCustomTemplateParams { + pub id: u64, + pub name: String, + pub region: ApiVmHostRegion, + pub max_cpu: u16, + pub min_cpu: u16, + pub min_memory: u64, + pub max_memory: u64, + pub min_disk: u64, + pub max_disk: u64, + pub disks: Vec, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema)] +pub struct ApiCustomTemplateDiskParam { + pub disk_type: DiskType, + pub disk_interface: DiskInterface, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema)] +pub struct ApiCustomVmRequest { + pub pricing_id: u64, + pub cpu: u16, + pub memory: u64, + pub disk: u64, + pub disk_type: DiskType, + pub disk_interface: DiskInterface, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct ApiCustomVmOrder { + #[serde(flatten)] + pub spec: ApiCustomVmRequest, + pub image_id: u64, + pub ssh_key_id: u64, + pub ref_code: Option, +} + +impl From for VmCustomTemplate { + fn from(value: ApiCustomVmRequest) -> Self { + VmCustomTemplate { + id: 0, + cpu: value.cpu, + memory: value.memory, + disk_size: value.disk, + disk_type: value.disk_type.into(), + disk_interface: value.disk_interface.into(), + pricing_id: value.pricing_id, + } + } +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct ApiCustomPrice { + pub currency: String, + pub amount: f32, +} + #[derive(Serialize, Deserialize, JsonSchema)] pub struct ApiVmTemplate { pub id: u64, @@ -123,33 +212,80 @@ pub struct ApiVmTemplate { } impl ApiVmTemplate { - pub fn from( - template: lnvps_db::VmTemplate, - cost_plan: lnvps_db::VmCostPlan, - region: VmHostRegion, - ) -> Self { - Self { + pub async fn from_standard(db: &Arc, template_id: u64) -> Result { + let template = db.get_vm_template(template_id).await?; + let cost_plan = db.get_cost_plan(template.cost_plan_id).await?; + let region = db.get_host_region(template.region_id).await?; + Ok(Self::from_standard_data(&template, &cost_plan, ®ion)) + } + + pub async fn from_custom(db: &Arc, vm_id: u64, 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?; + Ok(Self { id: template.id, - name: template.name, - created: template.created, - expires: template.expires, + name: "Custom".to_string(), + created: pricing.created, + expires: pricing.expires, cpu: template.cpu, memory: template.memory, disk_size: template.disk_size, disk_type: template.disk_type.into(), disk_interface: template.disk_interface.into(), cost_plan: ApiVmCostPlan { - id: cost_plan.id, - name: cost_plan.name, - amount: cost_plan.amount, - currency: cost_plan.currency, - interval_amount: cost_plan.interval_amount, - interval_type: cost_plan.interval_type.into(), + id: pricing.id, + name: pricing.name, + amount: price.total(), + currency: price.currency, + interval_amount: 1, + interval_type: ApiVmCostPlanIntervalType::Month, }, region: ApiVmHostRegion { id: region.id, name: region.name, }, + }) + } + + pub async fn from_vm(db: &Arc, vm: &Vm) -> Result { + if let Some(t) = vm.template_id { + return Self::from_standard(db, t).await; + } + if let Some(t) = vm.custom_template_id { + return Self::from_custom(db, vm.id, t).await; + } + bail!("Invalid VM config, no template or custom template") + } + + pub fn from_standard_data( + template: &VmTemplate, + cost_plan: &VmCostPlan, + region: &VmHostRegion, + ) -> Self { + Self { + id: template.id, + name: template.name.clone(), + created: template.created, + expires: template.expires, + cpu: template.cpu, + memory: template.memory, + disk_size: template.disk_size, + disk_type: template.disk_type.clone().into(), + disk_interface: template.disk_interface.clone().into(), + cost_plan: ApiVmCostPlan { + id: cost_plan.id, + name: cost_plan.name.clone(), + amount: cost_plan.amount, + currency: cost_plan.currency.clone(), + interval_amount: cost_plan.interval_amount, + interval_type: cost_plan.interval_type.clone().into(), + }, + region: ApiVmHostRegion { + id: region.id, + name: region.name.clone(), + }, } } } diff --git a/src/api/routes.rs b/src/api/routes.rs index dfc0c6a..716b1a5 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -1,16 +1,18 @@ use crate::api::model::{ - AccountPatchRequest, ApiUserSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, - ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest, + AccountPatchRequest, ApiCustomPrice, ApiCustomTemplateDiskParam, ApiCustomTemplateParams, + ApiCustomVmOrder, ApiCustomVmRequest, ApiTemplatesResponse, ApiUserSshKey, ApiVmHostRegion, + ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, + CreateVmRequest, VMPatchRequest, }; use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData}; use crate::nip98::Nip98Auth; -use crate::provisioner::{HostCapacityService, LNVpsProvisioner}; +use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine}; use crate::settings::Settings; use crate::status::{VmState, VmStateCache}; use crate::worker::WorkJob; -use anyhow::Result; +use anyhow::{Context, Result}; use futures::future::join_all; -use lnvps_db::{IpRange, LNVpsDb}; +use lnvps_db::{IpRange, LNVpsDb, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate}; use nostr::util::hex; use rocket::futures::{SinkExt, StreamExt}; use rocket::serde::json::Json; @@ -43,7 +45,9 @@ pub fn routes() -> Vec { v1_stop_vm, v1_restart_vm, v1_patch_vm, - v1_time_series + v1_time_series, + v1_custom_template_calc, + v1_create_custom_vm_order ] } @@ -127,9 +131,6 @@ async fn vm_to_status( state: Option, ) -> Result { let image = db.get_os_image(vm.image_id).await?; - let template = db.get_vm_template(vm.template_id).await?; - let region = db.get_host_region(template.region_id).await?; - let cost_plan = db.get_cost_plan(template.cost_plan_id).await?; let ssh_key = db.get_user_ssh_key(vm.ssh_key_id).await?; let ips = db.list_vm_ip_assignments(vm.id).await?; let ip_range_ids: HashSet = ips.iter().map(|i| i.ip_range_id).collect(); @@ -141,13 +142,14 @@ async fn vm_to_status( .map(|i| (i.id, i)) .collect(); + let template = ApiVmTemplate::from_vm(&db, &vm).await?; Ok(ApiVmStatus { id: vm.id, created: vm.created, expires: vm.expires, mac_address: vm.mac_address, image: image.into(), - template: ApiVmTemplate::from(template, cost_plan, region), + template, ssh_key: ssh_key.into(), status: state.unwrap_or_default(), ip_assignments: ips @@ -264,7 +266,7 @@ async fn v1_list_vm_images(db: &State>) -> ApiResult>) -> ApiResult> { +async fn v1_list_vm_templates(db: &State>) -> ApiResult { let hc = HostCapacityService::new((*db).clone()); let templates = hc.list_available_vm_templates().await?; @@ -295,14 +297,117 @@ async fn v1_list_vm_templates(db: &State>) -> ApiResult = + join_all(regions.iter().map(|(k, _)| db.list_custom_pricing(*k))) + .await + .into_iter() + .filter_map(|r| r.ok()) + .flatten() + .collect(); + let custom_template_disks: Vec = join_all( + custom_templates + .iter() + .map(|t| db.list_custom_pricing_disk(t.id)), + ) + .await + .into_iter() + .filter_map(|r| r.ok()) + .flatten() + .collect(); + + ApiData::ok(ApiTemplatesResponse { + templates: ret, + custom_template: if custom_templates.is_empty() { + None + } else { + const GB: u64 = 1024 * 1024 * 1024; + Some( + custom_templates + .into_iter() + .map(|t| ApiCustomTemplateParams { + id: t.id, + name: t.name, + region: regions + .get(&t.region_id) + .map(|r| ApiVmHostRegion { + id: r.id, + name: r.name.clone(), + }) + .context("Region information missing in custom template") + .unwrap(), + max_cpu: templates.iter().map(|t| t.cpu).max().unwrap_or(8), + min_cpu: 1, + min_memory: GB, + max_memory: templates.iter().map(|t| t.memory).max().unwrap_or(GB * 2), + min_disk: GB * 5, + max_disk: templates + .iter() + .map(|t| t.disk_size) + .max() + .unwrap_or(GB * 5), + disks: custom_template_disks + .iter() + .filter(|d| d.pricing_id == t.id) + .map(|d| ApiCustomTemplateDiskParam { + disk_type: d.kind.into(), + disk_interface: d.interface.into(), + }) + .collect(), + }) + .collect(), + ) + }, + }) +} + +/// Get a price for a custom order +#[openapi(tag = "VM")] +#[post("/api/v1/vm/custom-template/price", data = "", format = "json")] +async fn v1_custom_template_calc( + db: &State>, + req: Json, +) -> ApiResult { + // create a fake template from the request to generate the price + let template: VmCustomTemplate = req.0.into(); + + let price = PricingEngine::get_custom_vm_cost_amount(db, 0, &template).await?; + ApiData::ok(ApiCustomPrice { + currency: price.currency.clone(), + amount: price.total(), + }) +} + +/// Create a new VM order +/// +/// After order is created please use /api/v1/vm/{id}/renew to pay for VM, +/// VM's are initially created in "expired" state +/// +/// Unpaid VM orders will be deleted after 24hrs +#[openapi(tag = "VM")] +#[post("/api/v1/vm/custom-template", data = "", format = "json")] +async fn v1_create_custom_vm_order( + auth: Nip98Auth, + db: &State>, + provisioner: &State>, + req: Json, +) -> ApiResult { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await?; + + // create a fake template from the request to generate the order + let template = req.0.spec.clone().into(); + + let rsp = provisioner + .provision_custom(uid, template, req.image_id, req.ssh_key_id, req.0.ref_code) + .await?; + ApiData::ok(vm_to_status(db, rsp, None).await?) } /// List user SSH keys diff --git a/src/exchange.rs b/src/exchange.rs index 8fae904..a46d545 100644 --- a/src/exchange.rs +++ b/src/exchange.rs @@ -2,13 +2,14 @@ use anyhow::{Error, Result}; use lnvps_db::async_trait; use log::info; use rocket::serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)] pub enum Currency { EUR, BTC, diff --git a/src/host/mod.rs b/src/host/mod.rs index 06a5de1..3918532 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -3,8 +3,8 @@ use crate::status::VmState; use anyhow::{bail, Result}; use futures::future::join_all; use lnvps_db::{ - async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmHost, VmHostDisk, VmHostKind, VmIpAssignment, - VmOsImage, VmTemplate, + async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmCustomTemplate, VmHost, VmHostDisk, + VmHostKind, VmIpAssignment, VmOsImage, VmTemplate, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -87,7 +87,9 @@ pub struct FullVmInfo { /// Disk where this VM will be saved on the host pub disk: VmHostDisk, /// VM template resources - pub template: VmTemplate, + pub template: Option, + /// VM custom template resources + pub custom_template: Option, /// The OS image used to create the VM pub image: VmOsImage, /// List of IP resources assigned to this VM @@ -101,7 +103,6 @@ pub struct FullVmInfo { impl FullVmInfo { pub async fn load(vm_id: u64, db: Arc) -> Result { let vm = db.get_vm(vm_id).await?; - let template = db.get_vm_template(vm.template_id).await?; let image = db.get_os_image(vm.image_id).await?; let disk = db.get_host_disk(vm.disk_id).await?; let ssh_key = db.get_user_ssh_key(vm.ssh_key_id).await?; @@ -115,10 +116,21 @@ impl FullVmInfo { .filter_map(Result::ok) .collect(); + let template = if let Some(t) = vm.template_id { + Some(db.get_vm_template(t).await?) + } else { + None + }; + let custom_template = if let Some(t) = vm.custom_template_id { + Some(db.get_custom_vm_template(t).await?) + } else { + None + }; // create VM Ok(FullVmInfo { vm, template, + custom_template, image, ips, disk, @@ -126,6 +138,32 @@ impl FullVmInfo { ssh_key, }) } + + /// CPU cores + pub fn resources(&self) -> Result { + if let Some(t) = &self.template { + Ok(VmResources { + cpu: t.cpu, + memory: t.memory, + disk_size: t.disk_size, + }) + } else if let Some(t) = &self.custom_template { + Ok(VmResources { + cpu: t.cpu, + memory: t.memory, + disk_size: t.disk_size, + }) + } else { + bail!("Invalid VM config, no template"); + } + } +} + +#[derive(Clone)] +pub struct VmResources { + pub cpu: u16, + pub memory: u64, + pub disk_size: u64, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/src/host/proxmox.rs b/src/host/proxmox.rs index 13e4602..c80a7af 100644 --- a/src/host/proxmox.rs +++ b/src/host/proxmox.rs @@ -403,6 +403,7 @@ impl ProxmoxClient { net.push(format!("tag={}", t)); } + let vm_resources = value.resources()?; Ok(VmConfig { cpu: Some(self.config.cpu.clone()), kvm: Some(self.config.kvm), @@ -413,8 +414,8 @@ impl ProxmoxClient { on_boot: Some(true), bios: Some(VmBios::OVMF), boot: Some("order=scsi0".to_string()), - cores: Some(value.template.cpu as i32), - memory: Some((value.template.memory / 1024 / 1024).to_string()), + cores: Some(vm_resources.cpu as i32), + memory: Some((vm_resources.memory / 1024 / 1024).to_string()), scsi_hw: Some("virtio-scsi-pci".to_string()), serial_0: Some("socket".to_string()), scsi_1: Some(format!("{}:cloudinit", &value.disk.name)), @@ -512,7 +513,7 @@ impl VmHostClient for ProxmoxClient { node: self.node.clone(), vm_id, disk: "scsi0".to_string(), - size: req.template.disk_size.to_string(), + size: req.resources()?.disk_size.to_string(), }) .await?; // TODO: rollback diff --git a/src/mocks.rs b/src/mocks.rs index ab795a9..68516b9 100644 --- a/src/mocks.rs +++ b/src/mocks.rs @@ -1,19 +1,21 @@ #![allow(unused)] use crate::dns::{BasicRecord, DnsServer, RecordType}; +use crate::exchange::{ExchangeRateService, Ticker, TickerRate}; use crate::host::{FullVmInfo, TimeSeries, TimeSeriesData, VmHostClient}; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use crate::router::{ArpEntry, Router}; use crate::settings::NetworkPolicy; use crate::status::{VmRunningState, VmState}; -use anyhow::{anyhow, bail, ensure}; -use chrono::{DateTime, Utc}; +use anyhow::{anyhow, bail, ensure, Context}; +use chrono::{DateTime, TimeDelta, Utc}; use fedimint_tonic_lnd::tonic::codegen::tokio_stream::Stream; use lnvps_db::{ async_trait, DiskInterface, DiskType, IpRange, LNVpsDb, OsDistribution, User, UserSshKey, Vm, - VmCostPlan, VmCostPlanIntervalType, VmHost, VmHostDisk, VmHostKind, VmHostRegion, - VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, + VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use std::collections::HashMap; +use std::ops::Add; use std::pin::Pin; use std::sync::{Arc, LazyLock}; use tokio::sync::Mutex; @@ -31,21 +33,75 @@ pub struct MockDb { pub vms: Arc>>, pub ip_range: Arc>>, pub ip_assignments: Arc>>, + pub custom_pricing: Arc>>, + pub custom_pricing_disk: Arc>>, + pub custom_template: Arc>>, + pub payments: Arc>>, } impl MockDb { + pub const KB: u64 = 1024; + pub const MB: u64 = Self::KB * 1024; + pub const GB: u64 = Self::MB * 1024; + pub const TB: u64 = Self::GB * 1024; + pub fn empty() -> MockDb { Self { ..Default::default() } } + + pub fn mock_cost_plan() -> VmCostPlan { + VmCostPlan { + id: 1, + name: "mock".to_string(), + created: Utc::now(), + amount: 1.32, + currency: "EUR".to_string(), + interval_amount: 1, + interval_type: VmCostPlanIntervalType::Month, + } + } + + pub fn mock_template() -> VmTemplate { + VmTemplate { + id: 1, + name: "mock".to_string(), + enabled: true, + created: Utc::now(), + expires: None, + cpu: 2, + memory: Self::GB * 2, + disk_size: Self::GB * 64, + disk_type: DiskType::SSD, + disk_interface: DiskInterface::PCIe, + cost_plan_id: 1, + region_id: 1, + } + } + + pub fn mock_vm() ->Vm { + let template = Self::mock_template(); + Vm { + id: 1, + host_id: 1, + user_id: 1, + image_id: 1, + template_id: Some(template.id), + custom_template_id: None, + ssh_key_id: 1, + created: Utc::now(), + expires: Default::default(), + disk_id: 1, + mac_address: "ff:ff:ff:ff:ff:ff".to_string(), + deleted: false, + ref_code: None, + } + } } impl Default for MockDb { fn default() -> Self { - const GB: u64 = 1024 * 1024 * 1024; - const TB: u64 = GB * 1024; - let mut regions = HashMap::new(); regions.insert( 1, @@ -76,7 +132,7 @@ impl Default for MockDb { name: "mock-host".to_string(), ip: "https://localhost".to_string(), cpu: 4, - memory: 8 * GB, + memory: 8 * Self::GB, enabled: true, api_token: "".to_string(), load_factor: 1.5, @@ -89,43 +145,16 @@ impl Default for MockDb { id: 1, host_id: 1, name: "mock-disk".to_string(), - size: TB * 10, + size: Self::TB * 10, kind: DiskType::SSD, interface: DiskInterface::PCIe, enabled: true, }, ); let mut cost_plans = HashMap::new(); - cost_plans.insert( - 1, - VmCostPlan { - id: 1, - name: "mock".to_string(), - created: Utc::now(), - amount: 1f32, - currency: "EUR".to_string(), - interval_amount: 1, - interval_type: VmCostPlanIntervalType::Month, - }, - ); + cost_plans.insert(1, Self::mock_cost_plan()); let mut templates = HashMap::new(); - templates.insert( - 1, - VmTemplate { - id: 1, - name: "mock".to_string(), - enabled: true, - created: Utc::now(), - expires: None, - cpu: 2, - memory: GB * 2, - disk_size: GB * 64, - disk_type: DiskType::SSD, - disk_interface: DiskInterface::PCIe, - cost_plan_id: 1, - region_id: 1, - }, - ); + templates.insert(1, Self::mock_template()); let mut os_images = HashMap::new(); os_images.insert( 1, @@ -150,7 +179,11 @@ impl Default for MockDb { users: Arc::new(Default::default()), vms: Arc::new(Default::default()), ip_assignments: Arc::new(Default::default()), + custom_pricing: Arc::new(Default::default()), + custom_pricing_disk: Arc::new(Default::default()), user_ssh_keys: Arc::new(Mutex::new(Default::default())), + custom_template: Arc::new(Default::default()), + payments: Arc::new(Default::default()), } } } @@ -377,7 +410,12 @@ impl LNVpsDb for MockDb { self.get_host(vm.host_id).await?; self.get_user(vm.user_id).await?; self.get_os_image(vm.image_id).await?; - self.get_vm_template(vm.template_id).await?; + if let Some(t) = vm.template_id { + self.get_vm_template(t).await?; + } + if let Some(t) = vm.custom_template_id { + self.get_custom_vm_template(t).await?; + } self.get_user_ssh_key(vm.ssh_key_id).await?; self.get_host_disk(vm.disk_id).await?; @@ -462,27 +500,86 @@ impl LNVpsDb for MockDb { } async fn list_vm_payment(&self, vm_id: u64) -> anyhow::Result> { - todo!() + let p = self.payments.lock().await; + Ok(p.iter().filter(|p| p.vm_id == vm_id).cloned().collect()) } async fn insert_vm_payment(&self, vm_payment: &VmPayment) -> anyhow::Result<()> { - todo!() + let mut p = self.payments.lock().await; + p.push(vm_payment.clone()); + Ok(()) } async fn get_vm_payment(&self, id: &Vec) -> anyhow::Result { - todo!() + let p = self.payments.lock().await; + Ok(p.iter() + .find(|p| p.id == *id) + .context("no vm_payment")? + .clone()) } async fn update_vm_payment(&self, vm_payment: &VmPayment) -> anyhow::Result<()> { - todo!() + let mut p = self.payments.lock().await; + if let Some(p) = p.iter_mut().find(|p| p.id == *vm_payment.id) { + p.is_paid = vm_payment.is_paid.clone(); + p.settle_index = vm_payment.settle_index.clone(); + } + Ok(()) } - async fn vm_payment_paid(&self, id: &VmPayment) -> anyhow::Result<()> { - todo!() + async fn vm_payment_paid(&self, p: &VmPayment) -> anyhow::Result<()> { + let mut v = self.vms.lock().await; + self.update_vm_payment(p).await?; + if let Some(v) = v.get_mut(&p.vm_id) { + v.expires = v.expires.add(TimeDelta::seconds(p.time_value as i64)); + } + Ok(()) } async fn last_paid_invoice(&self) -> anyhow::Result> { - todo!() + let p = self.payments.lock().await; + Ok(p.iter() + .max_by(|a, b| a.settle_index.cmp(&b.settle_index)) + .map(|v| v.clone())) + } + + async fn list_custom_pricing(&self, region_id: u64) -> anyhow::Result> { + let p = self.custom_pricing.lock().await; + Ok(p.values().filter(|p| p.enabled).cloned().collect()) + } + + async fn get_custom_pricing(&self, id: u64) -> anyhow::Result { + let p = self.custom_pricing.lock().await; + Ok(p.get(&id).cloned().context("no custom pricing")?) + } + + async fn get_custom_vm_template(&self, id: u64) -> anyhow::Result { + let t = self.custom_template.lock().await; + Ok(t.get(&id).cloned().context("no custom template")?) + } + + async fn insert_custom_vm_template(&self, template: &VmCustomTemplate) -> anyhow::Result { + let mut t = self.custom_template.lock().await; + let max_id = *t.keys().max().unwrap_or(&0); + t.insert( + max_id + 1, + VmCustomTemplate { + id: max_id + 1, + ..template.clone() + }, + ); + Ok(max_id + 1) + } + + async fn list_custom_pricing_disk( + &self, + pricing_id: u64, + ) -> anyhow::Result> { + let d = self.custom_pricing_disk.lock().await; + Ok(d.values() + .filter(|d| d.pricing_id == pricing_id) + .cloned() + .collect()) } } @@ -673,7 +770,11 @@ impl VmHostClient for MockVmHost { Ok(()) } - async fn get_time_series_data(&self, vm: &Vm, series: TimeSeries) -> anyhow::Result> { + async fn get_time_series_data( + &self, + vm: &Vm, + series: TimeSeries, + ) -> anyhow::Result> { Ok(vec![]) } } @@ -758,3 +859,33 @@ impl DnsServer for MockDnsServer { Ok(record.clone()) } } + +pub struct MockExchangeRate { + pub rate: Arc>, +} + +impl MockExchangeRate { + pub fn new(rate: f32) -> Self { + Self { + rate: Arc::new(Mutex::new(rate)), + } + } +} + +#[async_trait] +impl ExchangeRateService for MockExchangeRate { + async fn fetch_rates(&self) -> anyhow::Result> { + let r = self.rate.lock().await; + Ok(vec![TickerRate(Ticker::btc_rate("EUR")?, *r)]) + } + + async fn set_rate(&self, ticker: Ticker, amount: f32) { + let mut r = self.rate.lock().await; + *r = amount; + } + + async fn get_rate(&self, ticker: Ticker) -> Option { + let r = self.rate.lock().await; + Some(*r) + } +} diff --git a/src/provisioner/capacity.rs b/src/provisioner/capacity.rs index 902e1da..067140c 100644 --- a/src/provisioner/capacity.rs +++ b/src/provisioner/capacity.rs @@ -1,6 +1,10 @@ +use crate::provisioner::Template; use anyhow::{bail, Result}; +use chrono::Utc; use futures::future::join_all; -use lnvps_db::{DiskType, LNVpsDb, VmHost, VmHostDisk, VmTemplate}; +use lnvps_db::{ + DiskInterface, DiskType, LNVpsDb, VmCustomTemplate, VmHost, VmHostDisk, VmTemplate, +}; use std::collections::HashMap; use std::sync::Arc; @@ -24,7 +28,7 @@ impl HostCapacityService { // use all hosts since we dont expect there to be many let hosts = self.db.list_hosts().await?; let caps: Vec> = - join_all(hosts.iter().map(|h| self.get_host_capacity(h, None))).await; + join_all(hosts.iter().map(|h| self.get_host_capacity(h, None, None))).await; let caps: Vec = caps.into_iter().filter_map(Result::ok).collect(); Ok(templates @@ -38,16 +42,21 @@ impl HostCapacityService { } /// Pick a host for the purposes of provisioning a new VM - pub async fn get_host_for_template(&self, template: &VmTemplate) -> Result { + pub async fn get_host_for_template( + &self, + region_id: u64, + template: &impl Template, + ) -> Result { let hosts = self.db.list_hosts().await?; - let caps: Vec> = join_all( - hosts - .iter() - .filter(|h| h.region_id == template.region_id) - // TODO: filter disk interface? - .map(|h| self.get_host_capacity(h, Some(template.disk_type.clone()))), - ) - .await; + let caps: Vec> = + join_all(hosts.iter().filter(|h| h.region_id == region_id).map(|h| { + self.get_host_capacity( + h, + Some(template.disk_type()), + Some(template.disk_interface()), + ) + })) + .await; let mut host_cap: Vec = caps .into_iter() .filter_map(|v| v.ok()) @@ -68,31 +77,76 @@ impl HostCapacityService { &self, host: &VmHost, disk_type: Option, + disk_interface: Option, ) -> Result { let vms = self.db.list_vms_on_host(host.id).await?; // TODO: filter disks from DB? Should be very few disks anyway let storage = self.db.list_host_disks(host.id).await?; let templates = self.db.list_vm_templates().await?; + let custom_templates: Vec> = join_all( + vms.iter() + .filter(|v| v.custom_template_id.is_some() && v.expires > Utc::now()) + .map(|v| { + self.db + .get_custom_vm_template(v.custom_template_id.unwrap()) + }), + ) + .await; + let custom_templates: HashMap = custom_templates + .into_iter() + .filter_map(|r| r.ok()) + .map(|v| (v.id, v)) + .collect(); - // a mapping between vm_id and template - let vm_template: HashMap = vms + struct VmResources { + vm_id: u64, + cpu: u16, + memory: u64, + disk: u64, + disk_id: u64, + } + // a mapping between vm_id and resources + let vm_resources: HashMap = vms .iter() + .filter(|v| v.expires > Utc::now()) .filter_map(|v| { - templates - .iter() - .find(|t| t.id == v.template_id) - .map(|t| (v.id, t)) + if let Some(x) = v.template_id { + templates.iter().find(|t| t.id == x).map(|t| VmResources { + vm_id: v.id, + cpu: t.cpu, + memory: t.memory, + disk: t.disk_size, + disk_id: v.disk_id, + }) + } else if let Some(x) = v.custom_template_id { + custom_templates.get(&x).map(|t| VmResources { + vm_id: v.id, + cpu: t.cpu, + memory: t.memory, + disk: t.disk_size, + disk_id: v.disk_id, + }) + } else { + None + } }) + .map(|m| (m.vm_id, m)) .collect(); let mut storage_disks: Vec = storage .iter() - .filter(|d| disk_type.as_ref().map(|t| d.kind == *t).unwrap_or(true)) + .filter(|d| { + disk_type.as_ref().map(|t| d.kind == *t).unwrap_or(true) + && disk_interface + .as_ref() + .map(|i| d.interface == *i) + .unwrap_or(true) + }) .map(|s| { - let usage = vm_template + let usage = vm_resources .iter() - .filter(|(k, v)| v.id == s.id) - .fold(0, |acc, (k, v)| acc + v.disk_size); + .filter(|(k, v)| s.id == v.disk_id) + .fold(0, |acc, (k, v)| acc + v.disk); DiskCapacity { load_factor: host.load_factor, disk: s.clone(), @@ -103,8 +157,8 @@ impl HostCapacityService { storage_disks.sort_by(|a, b| a.load_factor.partial_cmp(&b.load_factor).unwrap()); - let cpu_consumed = vm_template.values().fold(0, |acc, vm| acc + vm.cpu); - let memory_consumed = vm_template.values().fold(0, |acc, vm| acc + vm.memory); + let cpu_consumed = vm_resources.values().fold(0, |acc, vm| acc + vm.cpu); + let memory_consumed = vm_resources.values().fold(0, |acc, vm| acc + vm.memory); Ok(HostCapacity { load_factor: host.load_factor, @@ -164,13 +218,13 @@ impl HostCapacity { } /// Can this host and its available capacity accommodate the given template - pub fn can_accommodate(&self, template: &VmTemplate) -> bool { - self.available_cpu() >= template.cpu - && self.available_memory() >= template.memory + pub fn can_accommodate(&self, template: &impl Template) -> bool { + self.available_cpu() >= template.cpu() + && self.available_memory() >= template.memory() && self .disks .iter() - .any(|d| d.available_capacity() >= template.disk_size) + .any(|d| d.available_capacity() >= template.disk_size()) } } @@ -239,7 +293,7 @@ mod tests { let hc = HostCapacityService::new(db.clone()); let host = db.get_host(1).await?; - let cap = hc.get_host_capacity(&host, None).await?; + let cap = hc.get_host_capacity(&host, None, None).await?; let disks = db.list_host_disks(1).await?; /// check all resources are available assert_eq!(cap.cpu, 0); @@ -252,7 +306,9 @@ mod tests { } let template = db.get_vm_template(1).await?; - let host = hc.get_host_for_template(&template).await?; + let host = hc + .get_host_for_template(template.region_id, &template) + .await?; assert_eq!(host.host.id, 1); // all templates should be available @@ -261,4 +317,26 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn expired_doesnt_count() -> Result<()> { + let db = MockDb::default(); + { + let mut v = db.vms.lock().await; + v.insert(1, MockDb::mock_vm()); + } + + let db: Arc = Arc::new(db); + let hc = HostCapacityService::new(db.clone()); + let host = db.get_host(1).await?; + let cap = hc.get_host_capacity(&host, None, None).await?; + + assert_eq!(cap.load(), 0.0); + assert_eq!(cap.cpu, 0); + assert_eq!(cap.memory, 0); + for disk in cap.disks { + assert_eq!(0, disk.usage); + } + Ok(()) + } } diff --git a/src/provisioner/lnvps.rs b/src/provisioner/lnvps.rs index 054f1ce..e58b9be 100644 --- a/src/provisioner/lnvps.rs +++ b/src/provisioner/lnvps.rs @@ -2,12 +2,14 @@ use crate::dns::{BasicRecord, DnsServer}; use crate::exchange::{ExchangeRateService, Ticker}; use crate::host::{get_host_client, FullVmInfo}; use crate::lightning::{AddInvoiceRequest, LightningNode}; -use crate::provisioner::{HostCapacityService, NetworkProvisioner, ProvisionerMethod}; +use crate::provisioner::{ + CostResult, HostCapacityService, NetworkProvisioner, PricingEngine, ProvisionerMethod, +}; use crate::router::{ArpEntry, Router}; use crate::settings::{NetworkAccessPolicy, NetworkPolicy, ProvisionerConfig, Settings}; use anyhow::{bail, ensure, Context, Result}; use chrono::{Days, Months, Utc}; -use lnvps_db::{LNVpsDb, Vm, VmCostPlanIntervalType, VmIpAssignment, VmPayment}; +use lnvps_db::{DiskType, LNVpsDb, Vm, VmCostPlanIntervalType, VmCustomTemplate, VmIpAssignment, VmPayment}; use log::{info, warn}; use nostr::util::hex; use std::ops::Add; @@ -196,8 +198,8 @@ impl LNVpsProvisioner { // Use random network provisioner let network = NetworkProvisioner::new(ProvisionerMethod::Random, self.db.clone()); - let template = self.db.get_vm_template(vm.template_id).await?; - let ip = network.pick_ip_for_region(template.region_id).await?; + let host = self.db.get_host(vm.host_id).await?; + let ip = network.pick_ip_for_region(host.region_id).await?; let mut assignment = VmIpAssignment { id: 0, vm_id, @@ -253,7 +255,7 @@ impl LNVpsProvisioner { // TODO: cache capacity somewhere let cap = HostCapacityService::new(self.db.clone()); - let host = cap.get_host_for_template(&template).await?; + let host = cap.get_host_for_template(template.region_id, &template).await?; let pick_disk = if let Some(hd) = host.disks.first() { hd @@ -267,7 +269,64 @@ impl LNVpsProvisioner { host_id: host.host.id, user_id: user.id, image_id: image.id, - template_id: template.id, + template_id: Some(template.id), + custom_template_id: None, + ssh_key_id: ssh_key.id, + created: Utc::now(), + expires: Utc::now(), + disk_id: pick_disk.disk.id, + mac_address: "NOT FILLED YET".to_string(), + deleted: false, + ref_code, + }; + + // ask host client to generate the mac address + new_vm.mac_address = client.generate_mac(&new_vm).await?; + + let new_id = self.db.insert_vm(&new_vm).await?; + new_vm.id = new_id; + Ok(new_vm) + } + + /// Provision a new VM for a user on the database + /// + /// Note: + /// 1. Does not create a VM on the host machine + /// 2. Does not assign any IP resources + pub async fn provision_custom( + &self, + user_id: u64, + template: VmCustomTemplate, + image_id: u64, + ssh_key_id: u64, + ref_code: Option, + ) -> Result { + let user = self.db.get_user(user_id).await?; + let pricing = self.db.get_vm_template(template.pricing_id).await?; + let image = self.db.get_os_image(image_id).await?; + let ssh_key = self.db.get_user_ssh_key(ssh_key_id).await?; + + // TODO: cache capacity somewhere + let cap = HostCapacityService::new(self.db.clone()); + let host = cap.get_host_for_template(pricing.region_id, &template).await?; + + let pick_disk = if let Some(hd) = host.disks.first() { + hd + } else { + bail!("No host disk found") + }; + + // insert custom templates + let template_id = self.db.insert_custom_vm_template(&template).await?; + + let client = get_host_client(&host.host, &self.provisioner_config)?; + let mut new_vm = Vm { + id: 0, + host_id: host.host.id, + user_id: user.id, + image_id: image.id, + template_id: None, + custom_template_id: Some(template_id), ssh_key_id: ssh_key.id, created: Utc::now(), expires: Utc::now(), @@ -287,66 +346,44 @@ impl LNVpsProvisioner { /// Create a renewal payment pub async fn renew(&self, vm_id: u64) -> Result { - let vm = self.db.get_vm(vm_id).await?; - let template = self.db.get_vm_template(vm.template_id).await?; - let cost_plan = self.db.get_cost_plan(template.cost_plan_id).await?; + let pe = PricingEngine::new(self.db.clone(), self.rates.clone()); - // Reuse existing payment until expired - let payments = self.db.list_vm_payment(vm.id).await?; - if let Some(px) = payments - .into_iter() - .find(|p| p.expires > Utc::now() && !p.is_paid) - { - return Ok(px); + let price = pe.get_vm_cost(vm_id).await?; + match price { + CostResult::Existing(p) => Ok(p), + CostResult::New { + msats, + time_value, + new_expiry, + rate, + } => { + const INVOICE_EXPIRE: u64 = 600; + info!("Creating invoice for {vm_id} for {} sats", msats / 1000); + let invoice = self + .node + .add_invoice(AddInvoiceRequest { + memo: Some(format!("VM renewal {vm_id} to {new_expiry}")), + amount: msats, + expire: Some(INVOICE_EXPIRE as u32), + }) + .await?; + let vm_payment = VmPayment { + id: hex::decode(invoice.payment_hash)?, + vm_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), + amount: msats, + invoice: invoice.pr, + time_value, + is_paid: false, + rate, + settle_index: None, + }; + self.db.insert_vm_payment(&vm_payment).await?; + + Ok(vm_payment) + } } - - // push the expiration forward by cost plan interval amount - let new_expire = match cost_plan.interval_type { - VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)), - VmCostPlanIntervalType::Month => vm - .expires - .add(Months::new(cost_plan.interval_amount as u32)), - VmCostPlanIntervalType::Year => vm - .expires - .add(Months::new((12 * cost_plan.interval_amount) as u32)), - }; - - const BTC_SATS: f64 = 100_000_000.0; - const INVOICE_EXPIRE: u32 = 3600; - - let ticker = Ticker::btc_rate(cost_plan.currency.as_str())?; - let rate = if let Some(r) = self.rates.get_rate(ticker).await { - r - } else { - bail!("No exchange rate found") - }; - - let cost_btc = cost_plan.amount / rate; - let cost_msat = (cost_btc as f64 * BTC_SATS) as u64 * 1000; - info!("Creating invoice for {vm_id} for {} sats", cost_msat / 1000); - let invoice = self - .node - .add_invoice(AddInvoiceRequest { - memo: Some(format!("VM renewal {vm_id} to {new_expire}")), - amount: cost_msat, - expire: Some(INVOICE_EXPIRE), - }) - .await?; - let vm_payment = VmPayment { - id: hex::decode(invoice.payment_hash)?, - vm_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE as u64)), - amount: cost_msat, - invoice: invoice.pr, - time_value: (new_expire - vm.expires).num_seconds() as u64, - is_paid: false, - rate, - ..Default::default() - }; - self.db.insert_vm_payment(&vm_payment).await?; - - Ok(vm_payment) } /// Create a vm on the host as configured by the template @@ -401,8 +438,6 @@ mod tests { use std::str::FromStr; const ROUTER_BRIDGE: &str = "bridge1"; - const GB: u64 = 1024 * 1024 * 1024; - const TB: u64 = GB * 1024; fn settings() -> Settings { Settings { @@ -546,8 +581,8 @@ mod tests { created: Default::default(), expires: None, cpu: 64, - memory: 512 * GB, - disk_size: 20 * TB, + memory: 512 * MockDb::GB, + disk_size: 20 * MockDb::TB, disk_type: DiskType::SSD, disk_interface: DiskInterface::PCIe, cost_plan_id: 1, diff --git a/src/provisioner/mod.rs b/src/provisioner/mod.rs index f15d062..ba73281 100644 --- a/src/provisioner/mod.rs +++ b/src/provisioner/mod.rs @@ -1,7 +1,62 @@ mod capacity; mod lnvps; mod network; +mod pricing; pub use capacity::*; pub use lnvps::*; +use lnvps_db::{DiskInterface, DiskType, VmCustomTemplate, VmTemplate}; pub use network::*; +pub use pricing::*; + +pub trait Template { + fn cpu(&self) -> u16; + fn memory(&self) -> u64; + fn disk_size(&self) -> u64; + fn disk_type(&self) -> DiskType; + fn disk_interface(&self) -> DiskInterface; +} + +impl Template for VmTemplate { + fn cpu(&self) -> u16 { + self.cpu + } + + fn memory(&self) -> u64 { + self.memory + } + + fn disk_size(&self) -> u64 { + self.disk_size + } + + fn disk_type(&self) -> DiskType { + self.disk_type + } + + fn disk_interface(&self) -> DiskInterface { + self.disk_interface + } +} + +impl Template for VmCustomTemplate { + fn cpu(&self) -> u16 { + self.cpu + } + + fn memory(&self) -> u64 { + self.memory + } + + fn disk_size(&self) -> u64 { + self.disk_size + } + + fn disk_type(&self) -> DiskType { + self.disk_type + } + + fn disk_interface(&self) -> DiskInterface { + self.disk_interface + } +} \ No newline at end of file diff --git a/src/provisioner/pricing.rs b/src/provisioner/pricing.rs new file mode 100644 index 0000000..c165a20 --- /dev/null +++ b/src/provisioner/pricing.rs @@ -0,0 +1,299 @@ +use crate::exchange::{ExchangeRateService, Ticker}; +use anyhow::{bail, Result}; +use chrono::{DateTime, Days, Months, TimeDelta, Utc}; +use ipnetwork::IpNetwork; +use lnvps_db::{LNVpsDb, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomTemplate, VmPayment}; +use log::info; +use std::ops::Add; +use std::str::FromStr; +use std::sync::Arc; + +/// Pricing engine is used to calculate billing amounts for +/// different resource allocations +#[derive(Clone)] +pub struct PricingEngine { + db: Arc, + rates: Arc, +} + +impl PricingEngine { + /// SATS per BTC + const BTC_SATS: f64 = 100_000_000.0; + const KB: u64 = 1024; + const MB: u64 = Self::KB * 1024; + const GB: u64 = Self::MB * 1024; + + pub fn new(db: Arc, rates: Arc) -> Self { + Self { db, rates } + } + + /// Get VM cost (for renewal) + pub async fn get_vm_cost(&self, vm_id: u64) -> Result { + let vm = self.db.get_vm(vm_id).await?; + + // Reuse existing payment until expired + let payments = self.db.list_vm_payment(vm.id).await?; + if let Some(px) = payments + .into_iter() + .find(|p| p.expires > Utc::now() && !p.is_paid) + { + return Ok(CostResult::Existing(px)); + } + + if vm.template_id.is_some() { + Ok(self.get_template_vm_cost(&vm).await?) + } else { + Ok(self.get_custom_vm_cost(&vm).await?) + } + } + + /// 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?; + 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 + let disk_pricing = + if let Some(p) = pricing_disk.iter().find(|p| p.kind == template.disk_type) { + p + } else { + bail!("No disk price found") + }; + let disk_cost = (template.disk_size / Self::GB) as f32 * disk_pricing.cost; + let cpu_cost = pricing.cpu_cost * template.cpu as f32; + let memory_cost = pricing.memory_cost * (template.memory / Self::GB) as f32; + let ip4_cost = pricing.ip4_cost * v4s as f32; + let ip6_cost = pricing.ip6_cost * v6s as f32; + + Ok(PricingData { + currency: pricing.currency, + cpu_cost, + memory_cost, + ip6_cost, + ip4_cost, + disk_cost, + }) + } + + async fn get_custom_vm_cost(&self, vm: &Vm) -> Result { + let template_id = if let Some(i) = vm.custom_template_id { + i + } else { + bail!("Not a custom template vm") + }; + + 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?; + info!("Custom pricing for {} = {:?}", vm.id, price); + + // custom templates are always 1-month intervals + let time_value = (vm.expires.add(Months::new(1)) - vm.expires).num_seconds() as u64; + let (cost_msats, rate) = self + .get_msats_amount(&price.currency, price.total()) + .await?; + Ok(CostResult::New { + msats: cost_msats, + rate, + time_value, + new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)), + }) + } + + async fn get_msats_amount(&self, currency: &str, amount: f32) -> Result<(u64, f32)> { + let ticker = Ticker::btc_rate(¤cy)?; + let rate = if let Some(r) = self.rates.get_rate(ticker).await { + r + } else { + bail!("No exchange rate found") + }; + + let cost_btc = amount / rate; + let cost_msats = (cost_btc as f64 * Self::BTC_SATS) as u64 * 1000; + Ok((cost_msats, rate)) + } + + fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 { + let next_expire = match cost_plan.interval_type { + VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)), + VmCostPlanIntervalType::Month => vm + .expires + .add(Months::new(cost_plan.interval_amount as u32)), + VmCostPlanIntervalType::Year => vm + .expires + .add(Months::new((12 * cost_plan.interval_amount) as u32)), + }; + + (next_expire - vm.expires).num_seconds() as u64 + } + + async fn get_template_vm_cost(&self, vm: &Vm) -> Result { + let template_id = if let Some(i) = vm.template_id { + i + } else { + bail!("Not a standard template vm"); + }; + let template = self.db.get_vm_template(template_id).await?; + let cost_plan = self.db.get_cost_plan(template.cost_plan_id).await?; + + let (cost_msats, rate) = self + .get_msats_amount(cost_plan.currency.as_str(), cost_plan.amount) + .await?; + let time_value = Self::next_template_expire(&vm, &cost_plan); + Ok(CostResult::New { + msats: cost_msats, + rate, + time_value, + new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)), + }) + } +} + +#[derive(Clone)] +pub enum CostResult { + /// An existing payment already exists and should be used + Existing(VmPayment), + /// A new payment can be created with the specified amount + New { + /// The cost in milli-sats + msats: u64, + /// The exchange rate used to calculate the price + rate: f32, + /// The time to extend the vm expiry in seconds + time_value: u64, + /// The absolute expiry time of the vm if renewed + new_expiry: DateTime, + }, +} + +#[derive(Clone, Debug)] +pub struct PricingData { + pub currency: String, + pub cpu_cost: f32, + pub memory_cost: f32, + pub ip4_cost: f32, + pub ip6_cost: f32, + pub disk_cost: f32, +} + +impl PricingData { + pub fn total(&self) -> f32 { + self.cpu_cost + self.memory_cost + self.ip4_cost + self.ip6_cost + self.disk_cost + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mocks::{MockDb, MockExchangeRate}; + use lnvps_db::{DiskType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate}; + const GB: u64 = 1024 * 1024 * 1024; + const MOCK_RATE: f32 = 100_000.0; + + async fn add_custom_pricing(db: &MockDb) { + let mut p = db.custom_pricing.lock().await; + p.insert( + 1, + VmCustomPricing { + id: 1, + name: "mock-custom".to_string(), + enabled: true, + created: Utc::now(), + expires: None, + region_id: 1, + currency: "EUR".to_string(), + cpu_cost: 1.5, + memory_cost: 0.5, + ip4_cost: 0.5, + ip6_cost: 0.05, + }, + ); + let mut p = db.custom_template.lock().await; + p.insert( + 1, + VmCustomTemplate { + id: 1, + cpu: 2, + memory: 2 * GB, + disk_size: 80 * GB, + disk_type: DiskType::SSD, + disk_interface: Default::default(), + pricing_id: 1, + }, + ); + let mut d = db.custom_pricing_disk.lock().await; + d.insert( + 1, + VmCustomPricingDisk { + id: 1, + pricing_id: 1, + kind: DiskType::SSD, + interface: Default::default(), + cost: 0.05, + }, + ); + } + #[tokio::test] + async fn custom_pricing() -> Result<()> { + let db = MockDb::default(); + add_custom_pricing(&db).await; + 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?; + assert_eq!(3.0, price.cpu_cost); + assert_eq!(1.0, price.memory_cost); + assert_eq!(0.5, price.ip4_cost); + assert_eq!(0.05, price.ip6_cost); + assert_eq!(4.0, price.disk_cost); + assert_eq!(8.55, price.total()); + + Ok(()) + } + + #[tokio::test] + async fn standard_pricing() -> Result<()> { + let db = MockDb::default(); + let rates = Arc::new(MockExchangeRate::new(MOCK_RATE)); + // add basic vm + { + let mut v = db.vms.lock().await; + v.insert(1, MockDb::mock_vm()); + } + + let db: Arc = Arc::new(db); + + let pe = PricingEngine::new(db.clone(), rates); + let price = pe.get_vm_cost(1).await?; + let plan = MockDb::mock_cost_plan(); + match price { + CostResult::Existing(_) => bail!("??"), + CostResult::New { msats, .. } => { + let expect_price = (plan.amount / MOCK_RATE * 1.0e11) as u64; + assert_eq!(expect_price, msats); + } + } + + Ok(()) + } +}