This commit is contained in:
kieran 2024-11-24 23:09:29 +00:00
parent 1dd72fd011
commit 13f59908fb
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
19 changed files with 2774 additions and 306 deletions

2
.gitignore vendored
View File

@ -1,2 +1,2 @@
/target
**/target
.idea/

18
Cargo.lock generated
View File

@ -1383,6 +1383,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"config",
"lnvps_db",
"log",
"nostr",
"pretty_env_logger",
@ -1390,10 +1391,20 @@ dependencies = [
"rocket",
"serde",
"serde_json",
"sqlx",
"tokio",
]
[[package]]
name = "lnvps_db"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"serde",
"sqlx",
]
[[package]]
name = "lock_api"
version = "0.4.12"
@ -1543,10 +1554,11 @@ dependencies = [
[[package]]
name = "nostr"
version = "0.35.0"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56db234b2e07901e372f34e9463f91590579cd8e6dbd34ed2ccc7e461e4ba639"
checksum = "14ad56c1d9a59f4edc46b17bc64a217b38b99baefddc0080f85ad98a0855336d"
dependencies = [
"async-trait",
"base64 0.22.1",
"bech32",
"bip39",

View File

@ -7,16 +7,16 @@ edition = "2021"
name = "api"
[dependencies]
lnvps_db = { path = "lnvps_db" }
tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] }
anyhow = "1.0.83"
log = "0.4.21"
config = { version = "0.14.0", features = ["toml"] }
config = { version = "0.14.0", features = ["yaml"] }
pretty_env_logger = "0.5.0"
serde = { version = "1.0.213", features = ["derive"] }
reqwest = { version = "0.12.8", features = ["json"] }
serde_json = "1.0.132"
sqlx = { version = "0.8.2", features = ["mysql", "chrono", "migrate", "runtime-tokio"] }
rocket = { version = "0.5.1", features = ["json"] }
chrono = { version = "0.4.38", features = ["serde"] }
nostr = { version = "0.35.0", default-features = false, features = ["std"] }
nostr = { version = "0.36.0", default-features = false, features = ["std"] }
base64 = "0.22.1"

View File

@ -1,2 +0,0 @@
# MySQL database connection string
db = "mysql://root:root@localhost:3376/lnvps"

2
config.yaml Normal file
View File

@ -0,0 +1,2 @@
# MySQL database connection string
db: "mysql://root:root@localhost:3376/lnvps"

View File

@ -1,5 +1,15 @@
insert ignore into vm_host(id,kind,name,ip,cpu,memory,enabled,api_token) values(1, 0, "lab", "https://185.18.221.8:8006", 4, 4096*1024, 1, "root@pam!tester=c82f8a57-f876-4ca4-8610-c086d8d9d51c");
insert ignore into vm_host_disk(id,host_id,name,size,kind,interface,enabled) values(1,1,"local-lvm",1000*1000*1000*1000, 0, 0, 1);
insert ignore into vm_os_image(id,name,enabled) values(1,"Ubuntu 24.04",1);
insert ignore into ip_range(id,cidr,enabled) values(1,"185.18.221.80/28",1);
insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_id) values(1,"Basic",1,2,2048,1000*1000*1000*80,1);
insert
ignore into vm_host_region(id,name,enabled) values(1,"uat",1);
insert
ignore into vm_host(id,kind,region_id,name,ip,cpu,memory,enabled,api_token) values(1, 0, 1, "lab", "https://185.18.221.8:8006", 4, 4096*1024, 1, "root@pam!tester=c82f8a57-f876-4ca4-8610-c086d8d9d51c");
insert
ignore into vm_host_disk(id,host_id,name,size,kind,interface,enabled) values(1,1,"local-lvm",1000*1000*1000*1000, 0, 0, 1);
insert
ignore into vm_os_image(id,name,distribution,flavour,version,enabled,url) values(1,0,"Server","24.04",1,"https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img");
insert
ignore into ip_range(id,cidr,enabled) values(1,"185.18.221.80/28",1);
insert
ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) values(1,"tiny_monthly",3,"EUR",1,1);
insert
ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id)
values(1,"Tiny",1,2,1024*1024*2,1000*1000*1000*80,1,2,1,1);

2019
lnvps_db/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
lnvps_db/Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "lnvps_db"
version = "0.1.0"
edition = "2021"
[features]
default = ["mysql"]
mysql = ["sqlx/mysql"]
[dependencies]
anyhow = "1.0.83"
sqlx = { version = "0.8.2", features = ["chrono", "migrate", "runtime-tokio"] }
serde = { version = "1.0.213", features = ["derive"] }
chrono = { version = "0.4.38", features = ["serde"] }
async-trait = "0.1.83"

View File

@ -2,35 +2,43 @@ create table users
(
id integer unsigned not null auto_increment primary key,
pubkey binary(32) not null,
created timestamp default current_timestamp
created timestamp default current_timestamp,
email varchar(200),
contact_nip4 bit(1) not null,
contact_nip17 bit(1) not null,
contact_email bit(1) not null,
);
create unique index ix_user_pubkey on users (pubkey);
create unique index ix_user_email on users (email);
create table user_ssh_key
(
id integer unsigned not null auto_increment primary key,
name varchar(100) not null,
user_id integer unsigned not null,
created timestamp default current_timestamp,
key varchar(2048) not null,
key_data varchar(2048) not null,
constraint fk_ssh_key_user_id foreign key (user_id) references users (id)
constraint fk_ssh_key_user foreign key (user_id) references users (id)
);
create unique index ix_user_pubkey on users (pubkey);
create table vm_host_region
(
id integer unsigned not null auto_increment primary key,
name varchar(100) not null,
enabled bit(1) not null,
enabled bit(1) not null
);
create table vm_host
(
id integer unsigned not null auto_increment primary key,
kind smallint unsigned not null,
region_id integer unsigned not null,
name varchar(100) not null,
ip varchar(250) not null,
cpu bigint unsigned not null,
memory bigint unsigned not null,
enabled bit(1) not null,
api_token varchar(200) not null
api_token varchar(200) not null,
constraint fk_host_region foreign key (region_id) references vm_host_region (id)
);
create table vm_host_disk
(
@ -42,32 +50,39 @@ create table vm_host_disk
interface smallint unsigned not null,
enabled bit(1) not null,
constraint fk_vm_host_disk foreign key (host_id) references vm_host (id)
constraint fk_host_disk_host foreign key (host_id) references vm_host (id)
);
create table vm_os_image
(
id integer unsigned not null auto_increment primary key,
name varchar(200) not null,
enabled bit(1) not null
distribution smallint unsigned not null,
flavour varchar(50) not null,
version varchar(50) not null,
enabled bit(1) not null,
url varchar(1024) not null,
);
create unique index ix_vm_os_image_name on vm_os_image (name);
create table ip_range
(
id integer unsigned not null auto_increment primary key,
cidr varchar(200) not null,
enabled bit(1) not null
enabled bit(1) not null,
region_id integer unsigned not null,
constraint fk_ip_range_region foreign key (region_id) references vm_host_region (id)
);
create table vm_cost_plan
(
id integer unsigned not null auto_increment primary key,
name varchar(200) not null,
enabled bit(1) not null,
created timestamp default current_timestamp,
expires timestamp,
amount integer unsigned not null,
currency varchar(4) not null,
interval integer unsigned not null,
interval_type smallint unsigned not null,
interval_amount integer unsigned not null,
interval_type smallint unsigned not null
);
-- IE. VM Offers
create table vm_template
(
id integer unsigned not null auto_increment primary key,
@ -75,13 +90,18 @@ create table vm_template
enabled bit(1) not null,
created timestamp default current_timestamp,
expires timestamp,
cpu smallint unsigned not null,
cpu tinyint unsigned not null,
memory bigint unsigned not null,
disk_size bigint unsigned not null,
disk_id integer unsigned not null,
disk_type smallint unsigned not null,
disk_interface smallint unsigned not null,
cost_plan_id integer unsigned not null,
region_id integer unsigned not null,
constraint fk_vm_host_disk_id foreign key (disk_id) references vm_host_disk (id)
constraint fk_template_cost_plan foreign key (cost_plan_id) references vm_cost_plan (id),
constraint fk_template_region foreign key (region_id) references vm_template (id)
);
-- An instance of a VM
create table vm
(
id integer unsigned not null auto_increment primary key,
@ -121,7 +141,7 @@ create table vm_payment
expires timestamp not null,
amount bigint unsigned not null,
invoice varchar(2048) not null,
time_value integer unsigned not null,
time_value bigint unsigned not null,
is_paid bit(1) not null,
constraint fk_vm_payment_vm foreign key (vm_id) references vm (id)

82
lnvps_db/src/lib.rs Normal file
View File

@ -0,0 +1,82 @@
use anyhow::Result;
use async_trait::async_trait;
mod model;
#[cfg(feature = "mysql")]
mod mysql;
pub use model::*;
#[cfg(feature = "mysql")]
pub use mysql::*;
#[async_trait]
pub trait LNVpsDb: Sync + Send {
/// Migrate database
async fn migrate(&self) -> Result<()>;
/// Insert/Fetch user by pubkey
async fn upsert_user(&self, pubkey: &[u8; 32]) -> Result<u64>;
/// Get a user by id
async fn get_user(&self, id: u64) -> Result<User>;
/// Update user record
async fn update_user(&self, user: &User) -> Result<()>;
/// Delete user record
async fn delete_user(&self, id: u64) -> Result<()>;
/// Insert a new user ssh key
async fn insert_user_ssh_key(&self, new_key: UserSshKey) -> Result<u64>;
/// Get user ssh key by id
async fn get_user_ssh_key(&self, id: u64) -> Result<UserSshKey>;
/// Delete a user ssh key by id
async fn delete_user_ssh_key(&self, id: u64) -> Result<()>;
/// List a users ssh keys
async fn list_user_ssh_key(&self, user_id: u64) -> Result<Vec<UserSshKey>>;
/// Get VM host region by id
async fn get_host_region(&self, id: u64) -> Result<VmHostRegion>;
/// List VM's owned by a specific user
async fn list_hosts(&self) -> Result<Vec<VmHost>>;
/// Update host resources (usually from [auto_discover])
async fn update_host(&self, host: VmHost) -> Result<()>;
/// List VM's owned by a specific user
async fn list_host_disks(&self, host_id: u64) -> Result<Vec<VmHostDisk>>;
/// List available OS images
async fn list_os_image(&self) -> Result<Vec<VmOsImage>>;
/// List available IP Ranges
async fn list_ip_range(&self) -> Result<Vec<IpRange>>;
/// Get a VM cost plan by id
async fn get_cost_plan(&self, id: u64) -> Result<VmCostPlan>;
/// List VM templates
async fn list_vm_templates(&self) -> Result<Vec<VmTemplate>>;
/// List VM's owned by a specific user
async fn list_user_vms(&self, id: u64) -> Result<Vec<Vm>>;
/// Insert a new VM record
async fn insert_vm(&self, vm: Vm) -> Result<u64>;
/// List VM ip assignments
async fn get_vm_ip_assignments(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>>;
/// List payments by VM id
async fn list_vm_payment(&self, vm_id: u64) -> Result<Vec<VmPayment>>;
/// Insert a new VM payment record
async fn insert_vm_payment(&self, vm_payment: VmPayment) -> Result<u64>;
/// Update a VM payment record
async fn update_vm_payment(&self, vm_payment: VmPayment) -> Result<()>;
}

215
lnvps_db/src/model.rs Normal file
View File

@ -0,0 +1,215 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
/// Users who buy VM's
pub struct User {
/// Unique ID of this user (database generated)
pub id: u64,
/// The nostr public key for this user
pub pubkey: Vec<u8>,
/// When this user first started using the service (first login)
pub created: DateTime<Utc>,
pub email: Option<String>,
pub contact_nip4: bool,
pub contact_nip17: bool,
pub contact_email: bool,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct UserSshKey {
pub id: u64,
pub name: String,
pub user_id: u64,
pub created: DateTime<Utc>,
pub key_data: String,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub vms: Option<Vec<Vm>>,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[repr(u16)]
/// The type of VM host
pub enum VmHostKind {
Proxmox = 0,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmHostRegion {
pub id: u64,
pub name: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
/// A VM host
pub struct VmHost {
/// Unique id of this host
pub id: u64,
/// The host kind (Hypervisor)
pub kind: VmHostKind,
/// What region / group this host is part of
pub region_id: u64,
/// Internal name of this host
pub name: String,
/// Endpoint for controlling this host
pub ip: String,
/// Total number of CPU cores
pub cpu: u16,
/// Total memory size in bytes
pub memory: u64,
/// If VM's should be provisioned on this host
pub enabled: bool,
/// API token used to control this host via [ip]
pub api_token: String,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmHostDisk {
pub id: u64,
pub host_id: u64,
pub name: String,
pub size: u64,
pub kind: DiskType,
pub interface: DiskInterface,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[repr(u16)]
pub enum DiskType {
HDD = 0,
SSD = 1,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[repr(u16)]
pub enum DiskInterface {
SATA = 0,
SCSI = 1,
PCIe = 2,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[repr(u16)]
pub enum OsDistribution {
Ubuntu = 0,
Debian = 1,
}
/// OS Images are templates which are used as a basis for
/// provisioning new vms
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmOsImage {
pub id: u64,
pub name: String,
pub distribution: OsDistribution,
pub flavour: String,
pub version: String,
pub enabled: bool,
/// URL location of cloud image
pub url: String,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct IpRange {
pub id: u64,
pub cidr: String,
pub enabled: bool,
pub region_id: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[repr(u16)]
pub enum VmCostPlanIntervalType {
Day = 0,
Month = 1,
Year = 2,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmCostPlan {
pub id: u64,
pub name: String,
pub created: DateTime<Utc>,
pub amount: u64,
pub currency: String,
pub interval_amount: u64,
pub interval_type: VmCostPlanIntervalType,
}
/// Offers.
/// These are the same as the offers visible to customers
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmTemplate {
pub id: u64,
pub name: String,
pub enabled: bool,
pub created: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<DateTime<Utc>>,
pub cpu: u16,
pub memory: u64,
pub disk_type: DiskType,
pub disk_interface: DiskInterface,
pub cost_plan_id: u64,
pub region_id: u64,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_plan: Option<VmCostPlan>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<VmHostRegion>,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct Vm {
/// Unique VM ID (Same in proxmox)
pub id: u64,
/// The host this VM is on
pub host_id: u64,
/// The user that owns this 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,
/// Users ssh-key assigned to this VM
pub ssh_key_id: u64,
/// When the VM was created
pub created: DateTime<Utc>,
/// When the VM expires
pub expires: DateTime<Utc>,
/// How many vCPU's this VM has
pub cpu: u16,
/// How much RAM this VM has in bytes
pub memory: u64,
/// How big the disk is on this VM in bytes
pub disk_size: u64,
/// The [VmHostDisk] this VM is on
pub disk_id: u64,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmIpAssignment {
pub id: u64,
pub vm_id: u64,
pub ip_range_id: u64,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct VmPayment {
pub id: u64,
pub vm_id: u64,
pub created: DateTime<Utc>,
pub expires: DateTime<Utc>,
pub amount: u64,
pub invoice: String,
pub time_value: u64,
pub is_paid: bool,
}

152
lnvps_db/src/mysql.rs Normal file
View File

@ -0,0 +1,152 @@
use crate::{IpRange, LNVpsDb, User, UserSshKey, Vm, VmCostPlan, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate};
use anyhow::{Error, Result};
use async_trait::async_trait;
use sqlx::{Executor, MySqlPool, Row};
#[derive(Clone)]
pub struct LNVpsDbMysql {
db: MySqlPool,
}
impl LNVpsDbMysql {
pub async fn new(conn: &str) -> Result<Self> {
let db = MySqlPool::connect(conn).await?;
Ok(Self {
db
})
}
#[cfg(debug_assertions)]
pub async fn execute(&self, sql: &str) -> Result<()> {
self.db.execute(sql).await.map_err(Error::new)?;
Ok(())
}
}
#[async_trait]
impl LNVpsDb for LNVpsDbMysql {
async fn migrate(&self) -> anyhow::Result<()> {
sqlx::migrate!().run(&self.db).await.map_err(Error::new)
}
async fn upsert_user(&self, pubkey: &[u8; 32]) -> anyhow::Result<u64> {
let res = sqlx::query("insert ignore into users(pubkey) values(?) returning id")
.bind(pubkey.as_slice())
.fetch_optional(&self.db)
.await?;
match res {
None => sqlx::query("select id from users where pubkey = ?")
.bind(pubkey.as_slice())
.fetch_one(&self.db)
.await?
.try_get(0)
.map_err(Error::new),
Some(res) => res.try_get(0).map_err(Error::new),
}
}
async fn get_user(&self, id: u64) -> Result<User> {
todo!()
}
async fn update_user(&self, user: &User) -> Result<()> {
todo!()
}
async fn delete_user(&self, id: u64) -> Result<()> {
todo!()
}
async fn insert_user_ssh_key(&self, new_key: UserSshKey) -> Result<u64> {
todo!()
}
async fn get_user_ssh_key(&self, id: u64) -> Result<UserSshKey> {
todo!()
}
async fn delete_user_ssh_key(&self, id: u64) -> Result<()> {
todo!()
}
async fn list_user_ssh_key(&self, user_id: u64) -> Result<Vec<UserSshKey>> {
todo!()
}
async fn get_host_region(&self, id: u64) -> Result<VmHostRegion> {
todo!()
}
async fn list_hosts(&self) -> anyhow::Result<Vec<VmHost>> {
sqlx::query_as("select * from vm_host")
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
async fn update_host(&self, host: VmHost) -> anyhow::Result<()> {
sqlx::query("update vm_host set name = ?, cpu = ?, memory = ? where id = ?")
.bind(&host.name)
.bind(&host.cpu)
.bind(&host.memory)
.bind(&host.id)
.execute(&self.db)
.await?;
Ok(())
}
async fn list_host_disks(&self, host_id: u64) -> anyhow::Result<Vec<VmHostDisk>> {
sqlx::query_as("select * from vm_host_disk where host_id = ?")
.bind(&host_id)
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
async fn list_os_image(&self) -> Result<Vec<VmOsImage>> {
todo!()
}
async fn list_ip_range(&self) -> Result<Vec<IpRange>> {
todo!()
}
async fn get_cost_plan(&self, id: u64) -> Result<VmCostPlan> {
todo!()
}
async fn list_vm_templates(&self) -> anyhow::Result<Vec<VmTemplate>> {
sqlx::query_as("select * from vm_template where enabled = 1 and (expires is null or expires > now())")
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
async fn list_user_vms(&self, id: u64) -> Result<Vec<Vm>> {
sqlx::query_as("select * from vm where user_id = ?")
.bind(&id)
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
async fn insert_vm(&self, vm: Vm) -> Result<u64> {
todo!()
}
async fn get_vm_ip_assignments(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>> {
todo!()
}
async fn list_vm_payment(&self, vm_id: u64) -> Result<Vec<VmPayment>> {
todo!()
}
async fn insert_vm_payment(&self, vm_payment: VmPayment) -> Result<u64> {
todo!()
}
async fn update_vm_payment(&self, vm_payment: VmPayment) -> Result<()> {
todo!()
}
}

View File

@ -1,14 +1,13 @@
use crate::db;
use crate::nip98::Nip98Auth;
use crate::provisioner::Provisioner;
use anyhow::Error;
use lnvps_db::{LNVpsDb, Vm, VmTemplate};
use rocket::serde::json::Json;
use rocket::{get, post, routes, Data, Responder, Route, State};
use rocket::{get, post, routes, Responder, Route, State};
use serde::{Deserialize, Serialize};
use crate::vm::VMSpec;
pub fn routes() -> Vec<Route> {
routes![v1_list_vms]
routes![v1_list_vms, v1_list_vm_templates, v1_provision_vm]
}
type ApiResult<T> = Result<Json<ApiData<T>>, ApiError>;
@ -38,35 +37,40 @@ impl From<Error> for ApiError {
}
}
#[derive(Debug, Serialize, Deserialize)]
struct CreateVmRequest {}
impl From<CreateVmRequest> for VMSpec {
fn from(value: CreateVmRequest) -> Self {
todo!()
}
}
#[get("/api/v1/vms")]
async fn v1_list_vms(auth: Nip98Auth, provisioner: &State<Provisioner>) -> ApiResult<Vec<db::Vm>> {
async fn v1_list_vms(auth: Nip98Auth, db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<Vm>> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = provisioner.upsert_user(&pubkey).await?;
let vms = provisioner.list_vms(uid).await?;
let uid = db.upsert_user(&pubkey).await?;
let vms = db.list_user_vms(uid).await?;
ApiData::ok(vms)
}
#[get("/api/v1/vm/templates")]
async fn v1_list_vm_templates(provisioner: &State<Provisioner>) -> ApiResult<Vec<db::VmTemplate>> {
let vms = provisioner.list_vm_templates().await?;
async fn v1_list_vm_templates(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<VmTemplate>> {
let vms = db.list_vm_templates().await?;
ApiData::ok(vms)
}
#[post("/api/v1/vm", data = "<req>", format = "json")]
async fn v1_provision_vm(auth: Nip98Auth, provisioner: &State<Provisioner>, req: Json<CreateVmRequest>) -> ApiResult<db::Vm> {
async fn v1_provision_vm(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
provisioner: &State<Box<dyn Provisioner>>,
req: Json<CreateVmRequest>,
) -> ApiResult<Vm> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = provisioner.upsert_user(&pubkey).await?;
let uid = db.upsert_user(&pubkey).await?;
let req = req.0;
let rsp = provisioner.provision(req.into()).await?;
ApiData::ok(rsp)
}
#[derive(Deserialize)]
pub struct CreateVmRequest {}
impl Into<VmTemplate> for CreateVmRequest {
fn into(self) -> VmTemplate {
todo!()
}
}

View File

@ -2,10 +2,10 @@ use anyhow::Error;
use config::{Config, File};
use lnvps::api;
use lnvps::cors::CORS;
use lnvps::provisioner::Provisioner;
use lnvps::provisioner::{LNVpsProvisioner, Provisioner};
use lnvps_db::{LNVpsDb, LNVpsDbMysql};
use log::error;
use serde::{Deserialize, Serialize};
use sqlx::{Executor, MySqlPool};
#[derive(Debug, Deserialize, Serialize)]
pub struct Settings {
@ -17,13 +17,14 @@ async fn main() -> Result<(), Error> {
pretty_env_logger::init();
let config: Settings = Config::builder()
.add_source(File::with_name("config.toml"))
.add_source(File::with_name("config.yaml"))
.build()?
.try_deserialize()?;
let db = MySqlPool::connect(&config.db).await?;
sqlx::migrate!().run(&db).await?;
let provisioner = Provisioner::new(db.clone());
let db = LNVpsDbMysql::new(&config.db).await?;
db.migrate().await?;
let provisioner = LNVpsProvisioner::new(db.clone());
#[cfg(debug_assertions)]
{
let setup_script = include_str!("../../dev_setup.sql");
@ -31,9 +32,12 @@ async fn main() -> Result<(), Error> {
provisioner.auto_discover().await?;
}
let db: Box<dyn LNVpsDb> = Box::new(db.clone());
let pv: Box<dyn Provisioner> = Box::new(provisioner);
if let Err(e) = rocket::build()
.attach(CORS)
.manage(provisioner)
.manage(db)
.manage(pv)
.mount("/", api::routes())
.launch()
.await

114
src/db.rs
View File

@ -1,114 +0,0 @@
use chrono::{DateTime, Utc};
use rocket::serde::Serialize;
use sqlx::FromRow;
#[derive(Serialize, FromRow)]
/// Users who buy VM's
pub struct User {
/// Unique ID of this user (database generated)
pub id: u64,
/// The nostr public key for this user
pub pubkey: [u8; 32],
/// When this user first started using the service (first login)
pub created: DateTime<Utc>,
}
#[derive(Serialize, sqlx::Type, Clone, Debug)]
#[repr(u8)]
/// The type of VM host
pub enum VmHostKind {
Proxmox = 0,
}
#[derive(Serialize, FromRow, Clone, Debug)]
/// A VM host
pub struct VmHost {
pub id: u64,
pub kind: VmHostKind,
pub name: String,
pub ip: String,
/// Total number of CPU cores
pub cpu: u16,
/// Total memory size in bytes
pub memory: u64,
/// If VM's should be provisioned on this host
pub enabled: bool,
pub api_token: String,
}
#[derive(Serialize, FromRow, Clone, Debug)]
pub struct VmHostDisk {
pub id: u64,
pub host_id: u64,
pub name: String,
pub size: u64,
pub kind: DiskType,
pub interface: DiskInterface,
pub enabled: bool,
}
#[derive(Serialize, sqlx::Type, Clone, Debug)]
#[repr(u8)]
pub enum DiskType {
HDD = 0,
SSD = 1,
}
#[derive(Serialize, sqlx::Type, Clone, Debug)]
#[repr(u8)]
pub enum DiskInterface {
SATA = 0,
SCSI = 1,
PCIe = 2,
}
#[derive(Serialize, FromRow, Clone, Debug)]
pub struct VmOsImage {
pub id: u64,
pub name: String,
pub enabled: bool,
}
#[derive(Serialize, FromRow, Clone, Debug)]
pub struct IpRange {
pub id: u64,
pub cidr: String,
pub enabled: bool,
}
#[derive(Serialize, FromRow, Clone, Debug)]
pub struct VmTemplate {
pub id: u64,
pub name: String,
pub enabled: bool,
pub created: DateTime<Utc>,
pub expires: Option<DateTime<Utc>>,
pub cpu: u16,
pub memory: u64,
pub disk_size: u64,
pub disk_id: u64,
}
#[derive(Serialize, FromRow, Clone, Debug)]
pub struct Vm {
/// Unique VM ID (Same in proxmox)
pub id: u64,
/// The host this VM is on
pub host_id: u64,
/// The user that owns this VM
pub user_id: u64,
/// The base image of this VM
pub image_id: u64,
/// When the VM was created
pub created: DateTime<Utc>,
/// When the VM expires
pub expires: DateTime<Utc>,
/// How many vCPU's this VM has
pub cpu: u16,
/// How much RAM this VM has in bytes
pub memory: u64,
/// How big the disk is on this VM in bytes
pub disk_size: u64,
/// The [VmHostDisk] this VM is on
pub disk_id: u64,
}

View File

@ -1,7 +1,7 @@
use anyhow::Error;
use reqwest::{Body, ClientBuilder, Url};
use anyhow::Result;
use reqwest::{ClientBuilder, Url};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
pub struct ProxmoxClient {
@ -31,28 +31,35 @@ impl ProxmoxClient {
}
/// Get version info
pub async fn version(&self) -> Result<VersionResponse, Error> {
pub async fn version(&self) -> Result<VersionResponse> {
let rsp: ResponseBase<VersionResponse> = self.get("/api2/json/version").await?;
Ok(rsp.data)
}
/// List nodes
pub async fn list_nodes(&self) -> Result<Vec<NodeResponse>, Error> {
pub async fn list_nodes(&self) -> Result<Vec<NodeResponse>> {
let rsp: ResponseBase<Vec<NodeResponse>> = self.get("/api2/json/nodes").await?;
Ok(rsp.data)
}
pub async fn list_vms(&self, node: &str, full: bool) -> Result<Vec<VmInfo>, Error> {
pub async fn list_vms(&self, node: &str, full: bool) -> Result<Vec<VmInfo>> {
let rsp: ResponseBase<Vec<VmInfo>> =
self.get(&format!("/api2/json/nodes/{node}/qemu")).await?;
Ok(rsp.data)
}
pub async fn list_storage(&self) -> Result<Vec<NodeStorage>, Error> {
pub async fn list_storage(&self) -> Result<Vec<NodeStorage>> {
let rsp: ResponseBase<Vec<NodeStorage>> = self.get("/api2/json/storage").await?;
Ok(rsp.data)
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
pub async fn create_vm(&self, node: &str, req: CreateVm) -> Result<VmInfo> {
let rsp: ResponseBase<VmInfo> = self
.post(&format!("/api2/json/nodes/{node}/qemu"), req)
.await?;
Ok(rsp.data)
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
Ok(self
.client
.get(self.base.join(path)?)
@ -61,19 +68,15 @@ impl ProxmoxClient {
.await?
.json::<T>()
.await
.map_err(|e| Error::new(e))?)
.map_err(|e| anyhow::Error::new(e))?)
}
async fn post<T: DeserializeOwned, R: Into<Body>>(
&self,
path: &str,
body: R,
) -> Result<T, Error> {
async fn post<T: DeserializeOwned, R: Serialize>(&self, path: &str, body: R) -> Result<T> {
Ok(self
.client
.post(self.base.join(path)?)
.header("Authorization", format!("PVEAPIToken={}", self.token))
.body(body)
.json(&body)
.send()
.await?
.error_for_status()?
@ -165,3 +168,62 @@ pub struct NodeStorage {
#[serde(rename = "thinpool")]
pub thin_pool: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VmBios {
SeaBios,
OVMF,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct CreateVm {
pub node: String,
#[serde(rename = "vmid")]
pub vm_id: i32,
#[serde(rename = "onboot")]
#[serde(skip_serializing_if = "Option::is_none")]
pub on_boot: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub balloon: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bios: Option<VmBios>,
#[serde(skip_serializing_if = "Option::is_none")]
pub boot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cores: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<String>,
#[serde(rename = "ipconfig0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_config: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "net0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub net: Option<String>,
#[serde(rename = "ostype")]
#[serde(skip_serializing_if = "Option::is_none")]
pub os_type: Option<String>,
#[serde(rename = "scsi0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub scsi_0: Option<String>,
#[serde(rename = "scsi1")]
#[serde(skip_serializing_if = "Option::is_none")]
pub scsi_1: Option<String>,
#[serde(rename = "scsihw")]
#[serde(skip_serializing_if = "Option::is_none")]
pub scsi_hw: Option<String>,
#[serde(rename = "sshkeys")]
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_keys: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
#[serde(rename = "efidisk0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub efi_disk_0: Option<String>,
}

View File

@ -1,7 +1,5 @@
pub mod api;
pub mod cors;
pub mod db;
pub mod host;
mod nip98;
pub mod provisioner;
mod vm;

View File

@ -1,24 +1,27 @@
use crate::db;
use crate::db::{VmHost, VmHostDisk};
use crate::host::proxmox::ProxmoxClient;
use crate::vm::VMSpec;
use anyhow::{Error, Result};
use crate::host::proxmox::{CreateVm, ProxmoxClient, VmBios};
use anyhow::{bail, Result};
use lnvps_db::{LNVpsDb, Vm, VmTemplate};
use log::{info, warn};
use sqlx::{MySqlPool, Row};
use rocket::async_trait;
#[derive(Debug, Clone)]
pub struct Provisioner {
db: MySqlPool,
#[async_trait]
pub trait Provisioner: Send + Sync {
/// Provision a new VM
async fn provision(&self, spec: VmTemplate) -> Result<Vm>;
}
impl Provisioner {
pub fn new(db: MySqlPool) -> Self {
Self { db }
pub struct LNVpsProvisioner {
db: Box<dyn LNVpsDb>,
}
impl LNVpsProvisioner {
pub fn new(db: impl LNVpsDb + 'static) -> Self {
Self { db: Box::new(db) }
}
/// Auto-discover resources
pub async fn auto_discover(&self) -> Result<()> {
let hosts = self.list_hosts().await?;
let hosts = self.db.list_hosts().await?;
for host in hosts {
let api = ProxmoxClient::new(host.ip.parse()?).with_api_token(&host.api_token);
@ -32,11 +35,11 @@ impl Provisioner {
host.cpu = node.max_cpu.unwrap_or(host.cpu);
host.memory = node.max_mem.unwrap_or(host.memory);
info!("Patching host: {:?}", host);
self.update_host(host).await?;
self.db.update_host(host).await?;
}
// Update disk info
let storages = api.list_storage().await?;
let host_disks = self.list_host_disks(host.id).await?;
let host_disks = self.db.list_host_disks(host.id).await?;
for storage in storages {
let host_storage =
if let Some(s) = host_disks.iter().find(|d| d.name == storage.storage) {
@ -45,6 +48,8 @@ impl Provisioner {
warn!("Disk not found: {} on {}", storage.storage, host.name);
continue;
};
// TODO: patch host storage info
}
}
info!(
@ -56,72 +61,67 @@ impl Provisioner {
Ok(())
}
/// Provision a new VM
pub async fn provision(&self, spec: VMSpec) -> Result<db::Vm> {
todo!()
}
/// Insert/Fetch user id
pub async fn upsert_user(&self, pubkey: &[u8; 32]) -> Result<u64> {
let res = sqlx::query("insert ignore into users(pubkey) values(?) returning id")
.bind(pubkey.as_slice())
.fetch_optional(&self.db)
#[async_trait]
impl Provisioner for LNVpsProvisioner {
async fn provision(&self, spec: VmTemplate) -> Result<Vm> {
let hosts = self.db.list_hosts().await?;
// try any host
// TODO: impl resource usage based provisioning
for host in hosts {
let api = ProxmoxClient::new(host.ip.parse()?).with_api_token(&host.api_token);
let nodes = api.list_nodes().await?;
let node = if let Some(n) = nodes.iter().find(|n| n.name == host.name) {
n
} else {
continue;
};
let host_disks = self.db.list_host_disks(host.id).await?;
let disk_name = if let Some(d) = host_disks.first() {
d
} else {
continue;
};
let next_id = 101;
let vm_result = api
.create_vm(
&node.name,
CreateVm {
vm_id: next_id,
bios: Some(VmBios::OVMF),
boot: Some("order=scsi0".to_string()),
cores: Some(spec.cpu as i32),
cpu: Some("kvm64".to_string()),
memory: Some((spec.memory / 1024 / 1024).to_string()),
machine: Some("q35".to_string()),
scsi_hw: Some("virtio-scsi-pci".to_string()),
efi_disk_0: Some(format!("{}:vm-{next_id}-efi,size=1M", &disk_name.name)),
net: Some("virtio=auto,bridge=vmbr0,tag=100".to_string()),
ip_config: Some(format!("ip=auto,ipv6=auto")),
..Default::default()
},
)
.await?;
match res {
None => sqlx::query("select id from users where pubkey = ?")
.bind(pubkey.as_slice())
.fetch_one(&self.db)
.await?
.try_get(0)
.map_err(Error::new),
Some(res) => res.try_get(0).map_err(Error::new),
}
return Ok(Vm {
id: 0,
host_id: 0,
user_id: 0,
image_id: 0,
template_id: 0,
ssh_key_id: 0,
created: Default::default(),
expires: Default::default(),
cpu: 0,
memory: 0,
disk_size: 0,
disk_id: 0,
});
}
/// List VM templates
pub async fn list_vm_templates(&self) -> Result<Vec<db::VmTemplate>> {
sqlx::query_as("select * from vm_template where enabled = 1 and (expires is null or expires < now())")
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
/// List VM's owned by a specific user
pub async fn list_vms(&self, id: u64) -> Result<Vec<db::Vm>> {
sqlx::query_as("select * from vm where user_id = ?")
.bind(&id)
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
/// List VM's owned by a specific user
pub async fn list_hosts(&self) -> Result<Vec<VmHost>> {
sqlx::query_as("select * from vm_host")
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
/// List VM's owned by a specific user
pub async fn list_host_disks(&self, host_id: u64) -> Result<Vec<VmHostDisk>> {
sqlx::query_as("select * from vm_host_disk where host_id = ?")
.bind(&host_id)
.fetch_all(&self.db)
.await
.map_err(Error::new)
}
/// Update host resources (usually from [auto_discover])
pub async fn update_host(&self, host: VmHost) -> Result<()> {
sqlx::query("update vm_host set name = ?, cpu = ?, memory = ? where id = ?")
.bind(&host.name)
.bind(&host.cpu)
.bind(&host.memory)
.bind(&host.id)
.execute(&self.db)
.await?;
Ok(())
bail!("Failed to create VM")
}
}

View File

@ -1,11 +0,0 @@
pub enum DiskType {
SSD,
HDD,
}
pub struct VMSpec {
pub cpu: u16,
pub memory: u64,
pub disk: u64,
pub disk_type: DiskType,
}