feat: custom pricing

closes #3
This commit is contained in:
2025-03-06 21:42:27 +00:00
parent 36ba1f836a
commit 8c3756e3e8
15 changed files with 1242 additions and 359 deletions

160
lnvps_db/Cargo.lock generated
View File

@ -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"

View File

@ -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);

View File

@ -139,4 +139,19 @@ pub trait LNVpsDb: Sync + Send {
/// Return the most recently settled invoice
async fn last_paid_invoice(&self) -> Result<Option<VmPayment>>;
/// Return the list of active custom pricing models for a given region
async fn list_custom_pricing(&self, region_id: u64) -> Result<Vec<VmCustomPricing>>;
/// Get a custom pricing model
async fn get_custom_pricing(&self, id: u64) -> Result<VmCustomPricing>;
/// Get a custom pricing model
async fn get_custom_vm_template(&self, id: u64) -> Result<VmCustomTemplate>;
/// Insert custom vm template
async fn insert_custom_vm_template(&self, template: &VmCustomTemplate) -> Result<u64>;
/// Return the list of disk prices for a given custom pricing model
async fn list_custom_pricing_disk(&self, pricing_id: u64) -> Result<Vec<VmCustomPricingDisk>>;
}

View File

@ -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<Utc>,
pub expires: Option<DateTime<Utc>>,
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<u64>,
/// Custom pricing specification used for this vm [VmCustomTemplate]
pub custom_template_id: Option<u64>,
/// Users ssh-key assigned to this VM
pub ssh_key_id: u64,
/// When the VM was created

View File

@ -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<u64> {
@ -210,7 +213,7 @@ impl LNVpsDb for LNVpsDbMysql {
}
async fn list_vm_templates(&self) -> Result<Vec<VmTemplate>> {
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<u64> {
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<u64> {
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<Vec<VmCustomPricing>> {
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<VmCustomPricing> {
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<VmCustomTemplate> {
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<u64> {
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<Vec<VmCustomPricingDisk>> {
sqlx::query_as("select * from vm_custom_pricing_disk where pricing_id=?")
.bind(pricing_id)
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
}

View File

@ -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<lnvps_db::DiskType> for DiskType {
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
impl Into<lnvps_db::DiskType> 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<lnvps_db::DiskInterface> for DiskInterface {
}
}
impl From<DiskInterface> 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<ApiVmTemplate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_template: Option<Vec<ApiCustomTemplateParams>>,
}
#[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<ApiCustomTemplateDiskParam>,
}
#[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<String>,
}
impl From<ApiCustomVmRequest> 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<dyn LNVpsDb>, template_id: u64) -> Result<Self> {
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, &region))
}
pub async fn from_custom(db: &Arc<dyn LNVpsDb>, vm_id: u64, template_id: u64) -> Result<Self> {
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<dyn LNVpsDb>, vm: &Vm) -> Result<Self> {
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(),
},
}
}
}

View File

@ -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<Route> {
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<VmState>,
) -> Result<ApiVmStatus> {
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<u64> = 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<Arc<dyn LNVpsDb>>) -> ApiResult<Vec<ApiVmO
/// List available VM templates (Offers)
#[openapi(tag = "VM")]
#[get("/api/v1/vm/templates")]
async fn v1_list_vm_templates(db: &State<Arc<dyn LNVpsDb>>) -> ApiResult<Vec<ApiVmTemplate>> {
async fn v1_list_vm_templates(db: &State<Arc<dyn LNVpsDb>>) -> ApiResult<ApiTemplatesResponse> {
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<Arc<dyn LNVpsDb>>) -> ApiResult<Vec<Api
.collect();
let ret = templates
.into_iter()
.iter()
.filter_map(|i| {
let cp = cost_plans.get(&i.cost_plan_id)?;
let hr = regions.get(&i.region_id)?;
Some(ApiVmTemplate::from(i, cp.clone(), hr.clone()))
Some(ApiVmTemplate::from_standard_data(i, cp, hr))
})
.collect();
ApiData::ok(ret)
let custom_templates: Vec<VmCustomPricing> =
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<VmCustomPricingDisk> = 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 = "<req>", format = "json")]
async fn v1_custom_template_calc(
db: &State<Arc<dyn LNVpsDb>>,
req: Json<ApiCustomVmRequest>,
) -> ApiResult<ApiCustomPrice> {
// 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 = "<req>", format = "json")]
async fn v1_create_custom_vm_order(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
provisioner: &State<Arc<LNVpsProvisioner>>,
req: Json<ApiCustomVmOrder>,
) -> ApiResult<ApiVmStatus> {
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

View File

@ -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,

View File

@ -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<VmTemplate>,
/// VM custom template resources
pub custom_template: Option<VmCustomTemplate>,
/// 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<dyn LNVpsDb>) -> Result<Self> {
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<VmResources> {
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)]

View File

@ -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

View File

@ -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<Mutex<HashMap<u64, Vm>>>,
pub ip_range: Arc<Mutex<HashMap<u64, IpRange>>>,
pub ip_assignments: Arc<Mutex<HashMap<u64, VmIpAssignment>>>,
pub custom_pricing: Arc<Mutex<HashMap<u64, VmCustomPricing>>>,
pub custom_pricing_disk: Arc<Mutex<HashMap<u64, VmCustomPricingDisk>>>,
pub custom_template: Arc<Mutex<HashMap<u64, VmCustomTemplate>>>,
pub payments: Arc<Mutex<Vec<VmPayment>>>,
}
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<Vec<VmPayment>> {
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<u8>) -> anyhow::Result<VmPayment> {
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<Option<VmPayment>> {
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<Vec<VmCustomPricing>> {
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<VmCustomPricing> {
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<VmCustomTemplate> {
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<u64> {
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<Vec<VmCustomPricingDisk>> {
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<Vec<TimeSeriesData>> {
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> anyhow::Result<Vec<TimeSeriesData>> {
Ok(vec![])
}
}
@ -758,3 +859,33 @@ impl DnsServer for MockDnsServer {
Ok(record.clone())
}
}
pub struct MockExchangeRate {
pub rate: Arc<Mutex<f32>>,
}
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<Vec<TickerRate>> {
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<f32> {
let r = self.rate.lock().await;
Some(*r)
}
}

View File

@ -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<Result<HostCapacity>> =
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<HostCapacity> = 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<HostCapacity> {
pub async fn get_host_for_template(
&self,
region_id: u64,
template: &impl Template,
) -> Result<HostCapacity> {
let hosts = self.db.list_hosts().await?;
let caps: Vec<Result<HostCapacity>> = 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<Result<HostCapacity>> =
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<HostCapacity> = caps
.into_iter()
.filter_map(|v| v.ok())
@ -68,31 +77,76 @@ impl HostCapacityService {
&self,
host: &VmHost,
disk_type: Option<DiskType>,
disk_interface: Option<DiskInterface>,
) -> Result<HostCapacity> {
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<Result<VmCustomTemplate>> = 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<u64, VmCustomTemplate> = 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<u64, &VmTemplate> = 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<u64, VmResources> = 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<DiskCapacity> = 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<dyn LNVpsDb> = 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(())
}
}

View File

@ -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<String>,
) -> Result<Vm> {
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<VmPayment> {
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,

View File

@ -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
}
}

299
src/provisioner/pricing.rs Normal file
View File

@ -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<dyn LNVpsDb>,
rates: Arc<dyn ExchangeRateService>,
}
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<dyn LNVpsDb>, rates: Arc<dyn ExchangeRateService>) -> Self {
Self { db, rates }
}
/// Get VM cost (for renewal)
pub async fn get_vm_cost(&self, vm_id: u64) -> Result<CostResult> {
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<dyn LNVpsDb>,
vm_id: u64,
template: &VmCustomTemplate,
) -> Result<PricingData> {
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<CostResult> {
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(&currency)?;
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<CostResult> {
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<Utc>,
},
}
#[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<dyn LNVpsDb> = 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<dyn LNVpsDb> = 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(())
}
}