api progress

This commit is contained in:
2024-11-25 22:45:27 +00:00
parent 13f59908fb
commit a0e49d83bd
13 changed files with 1414 additions and 163 deletions

View File

@ -1,13 +1,22 @@
use crate::nip98::Nip98Auth;
use crate::provisioner::Provisioner;
use anyhow::Error;
use lnvps_db::{LNVpsDb, Vm, VmTemplate};
use lnvps_db::hydrate::Hydrate;
use lnvps_db::{LNVpsDb, UserSshKey, Vm, VmOsImage, VmPayment, VmTemplate};
use rocket::serde::json::Json;
use rocket::{get, post, routes, Responder, Route, State};
use serde::{Deserialize, Serialize};
use ssh_key::PublicKey;
pub fn routes() -> Vec<Route> {
routes![v1_list_vms, v1_list_vm_templates, v1_provision_vm]
routes![
v1_list_vms,
v1_list_vm_templates,
v1_list_vm_images,
v1_list_ssh_keys,
v1_add_ssh_key,
v1_create_vm_order,
v1_renew_vm
]
}
type ApiResult<T> = Result<Json<ApiData<T>>, ApiError>;
@ -21,6 +30,9 @@ impl<T: Serialize> ApiData<T> {
pub fn ok(data: T) -> ApiResult<T> {
Ok(Json::from(ApiData { data }))
}
pub fn err(msg: &str) -> ApiResult<T> {
Err(msg.into())
}
}
#[derive(Responder)]
@ -29,30 +41,86 @@ struct ApiError {
pub error: String,
}
impl From<Error> for ApiError {
fn from(value: Error) -> Self {
impl ApiError {
pub fn new(error: &str) -> Self {
Self {
error: error.to_owned(),
}
}
}
impl<T: ToString> From<T> for ApiError {
fn from(value: T) -> Self {
Self {
error: value.to_string(),
}
}
}
#[get("/api/v1/vms")]
#[get("/api/v1/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 = db.upsert_user(&pubkey).await?;
let vms = db.list_user_vms(uid).await?;
let mut vms = db.list_user_vms(uid).await?;
for vm in &mut vms {
vm.hydrate_up(db).await?;
}
ApiData::ok(vms)
}
#[get("/api/v1/image")]
async fn v1_list_vm_images(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<VmOsImage>> {
let vms = db.list_os_image().await?;
ApiData::ok(vms)
}
#[get("/api/v1/vm/templates")]
async fn v1_list_vm_templates(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<VmTemplate>> {
let vms = db.list_vm_templates().await?;
let mut vms = db.list_vm_templates().await?;
for vm in &mut vms {
vm.hydrate_up(db).await?;
}
ApiData::ok(vms)
}
#[get("/api/v1/ssh-key")]
async fn v1_list_ssh_keys(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
) -> ApiResult<Vec<UserSshKey>> {
let uid = db.upsert_user(&auth.event.pubkey.to_bytes()).await?;
let keys = db.list_user_ssh_key(uid).await?;
ApiData::ok(keys)
}
#[post("/api/v1/ssh-key", data = "<req>", format = "json")]
async fn v1_add_ssh_key(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
req: Json<CreateSshKey>,
) -> ApiResult<UserSshKey> {
let uid = db.upsert_user(&auth.event.pubkey.to_bytes()).await?;
let pk: PublicKey = req.key_data.parse()?;
let key_name = if !req.name.is_empty() {
&req.name
} else {
pk.comment()
};
let mut new_key = UserSshKey {
name: key_name.to_string(),
user_id: uid,
key_data: pk.to_openssh()?,
..Default::default()
};
let key_id = db.insert_user_ssh_key(&new_key).await?;
new_key.id = key_id;
ApiData::ok(new_key)
}
#[post("/api/v1/vm", data = "<req>", format = "json")]
async fn v1_provision_vm(
async fn v1_create_vm_order(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
provisioner: &State<Box<dyn Provisioner>>,
@ -62,15 +130,50 @@ async fn v1_provision_vm(
let uid = db.upsert_user(&pubkey).await?;
let req = req.0;
let rsp = provisioner.provision(req.into()).await?;
let mut rsp = provisioner
.provision(uid, req.template_id, req.image_id, req.ssh_key_id)
.await?;
rsp.hydrate_up(db).await?;
ApiData::ok(rsp)
}
#[get("/api/v1/vm/<id>/renew")]
async fn v1_renew_vm(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
provisioner: &State<Box<dyn Provisioner>>,
id: u64,
) -> ApiResult<VmPayment> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let vm = db.get_vm(id).await?;
if uid != vm.user_id {
return ApiData::err("VM does not belong to you");
}
let rsp = provisioner.renew(id).await?;
ApiData::ok(rsp)
}
#[derive(Deserialize)]
pub struct CreateVmRequest {}
struct CreateVmRequest {
template_id: u64,
image_id: u64,
ssh_key_id: u64,
}
impl Into<VmTemplate> for CreateVmRequest {
fn into(self) -> VmTemplate {
todo!()
impl From<CreateVmRequest> for VmTemplate {
fn from(val: CreateVmRequest) -> Self {
VmTemplate {
id: val.template_id,
..Default::default()
}
}
}
#[derive(Deserialize)]
struct CreateSshKey {
name: String,
key_data: String,
}

View File

@ -1,15 +1,25 @@
use anyhow::Error;
use config::{Config, File};
use fedimint_tonic_lnd::connect;
use lnvps::api;
use lnvps::cors::CORS;
use lnvps::provisioner::{LNVpsProvisioner, Provisioner};
use lnvps_db::{LNVpsDb, LNVpsDbMysql};
use log::error;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize)]
pub struct Settings {
pub db: String,
pub lnd: LndConfig,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LndConfig {
pub url: String,
pub cert: PathBuf,
pub macaroon: PathBuf,
}
#[rocket::main]
@ -24,7 +34,8 @@ async fn main() -> Result<(), Error> {
let db = LNVpsDbMysql::new(&config.db).await?;
db.migrate().await?;
let provisioner = LNVpsProvisioner::new(db.clone());
let lnd = connect(config.lnd.url, config.lnd.cert, config.lnd.macaroon).await?;
let provisioner = LNVpsProvisioner::new(db.clone(), lnd.clone());
#[cfg(debug_assertions)]
{
let setup_script = include_str!("../../dev_setup.sql");

View File

@ -1,4 +1,5 @@
use anyhow::Result;
use log::info;
use reqwest::{ClientBuilder, Url};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@ -42,46 +43,56 @@ impl ProxmoxClient {
Ok(rsp.data)
}
pub async fn get_vm_status(&self, node: &str, vm_id: i32) -> Result<VmInfo> {
let rsp: ResponseBase<VmInfo> = self
.get(&format!(
"/api2/json/nodes/{node}/qemu/{vm_id}/status/current"
))
.await?;
Ok(rsp.data)
}
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>> {
let rsp: ResponseBase<Vec<NodeStorage>> = self.get("/api2/json/storage").await?;
Ok(rsp.data)
}
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)
pub async fn create_vm(&self, req: CreateVm) -> Result<VmInfo> {
info!("{}", serde_json::to_string_pretty(&req)?);
let _rsp: ResponseBase<Option<String>> = self
.post(&format!("/api2/json/nodes/{}/qemu", req.node), &req)
.await?;
Ok(rsp.data)
self.get_vm_status(&req.node, req.vm_id).await
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
Ok(self
.client
self.client
.get(self.base.join(path)?)
.header("Authorization", format!("PVEAPIToken={}", self.token))
.send()
.await?
.json::<T>()
.await
.map_err(|e| anyhow::Error::new(e))?)
.map_err(anyhow::Error::new)
}
async fn post<T: DeserializeOwned, R: Serialize>(&self, path: &str, body: R) -> Result<T> {
Ok(self
let rsp = self
.client
.post(self.base.join(path)?)
.header("Authorization", format!("PVEAPIToken={}", self.token))
.json(&body)
.json::<R>(&body)
.send()
.await?
.error_for_status()?
.json()
.await?)
.await?;
let rsp = rsp.text().await?;
info!("<< {}", rsp);
Ok(serde_json::from_str(&rsp)?)
}
}

View File

@ -1,22 +1,42 @@
use crate::host::proxmox::{CreateVm, ProxmoxClient, VmBios};
use crate::host::proxmox::ProxmoxClient;
use anyhow::{bail, Result};
use lnvps_db::{LNVpsDb, Vm, VmTemplate};
use chrono::{Days, Months, Utc};
use fedimint_tonic_lnd::lnrpc::Invoice;
use fedimint_tonic_lnd::Client;
use lnvps_db::{LNVpsDb, Vm, VmCostPlanIntervalType, VmOsImage, VmPayment};
use log::{info, warn};
use rocket::async_trait;
use rocket::yansi::Paint;
use std::ops::Add;
use std::path::PathBuf;
use std::time::Duration;
#[async_trait]
pub trait Provisioner: Send + Sync {
/// Provision a new VM
async fn provision(&self, spec: VmTemplate) -> Result<Vm>;
async fn provision(
&self,
user_id: u64,
template_id: u64,
image_id: u64,
ssh_key_id: u64,
) -> Result<Vm>;
/// Create a renewal payment
async fn renew(&self, vm_id: u64) -> Result<VmPayment>;
}
pub struct LNVpsProvisioner {
db: Box<dyn LNVpsDb>,
lnd: Client,
}
impl LNVpsProvisioner {
pub fn new(db: impl LNVpsDb + 'static) -> Self {
Self { db: Box::new(db) }
pub fn new(db: impl LNVpsDb + 'static, lnd: Client) -> Self {
Self {
db: Box::new(db),
lnd,
}
}
/// Auto-discover resources
@ -35,7 +55,7 @@ impl LNVpsProvisioner {
host.cpu = node.max_cpu.unwrap_or(host.cpu);
host.memory = node.max_mem.unwrap_or(host.memory);
info!("Patching host: {:?}", host);
self.db.update_host(host).await?;
self.db.update_host(&host).await?;
}
// Update disk info
let storages = api.list_storage().await?;
@ -61,67 +81,113 @@ impl LNVpsProvisioner {
Ok(())
}
fn map_os_image(image: &VmOsImage) -> PathBuf {
PathBuf::from("/var/lib/vz/images/").join(format!(
"{:?}_{}_{}.img",
image.distribution, image.flavour, image.version
))
}
}
#[async_trait]
impl Provisioner for LNVpsProvisioner {
async fn provision(&self, spec: VmTemplate) -> Result<Vm> {
async fn provision(
&self,
user_id: u64,
template_id: u64,
image_id: u64,
ssh_key_id: u64,
) -> Result<Vm> {
let user = self.db.get_user(user_id).await?;
let template = self.db.get_vm_template(template_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?;
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 pick_host = if let Some(h) = hosts.first() {
h
} else {
bail!("No host found")
};
let host_disks = self.db.list_host_disks(pick_host.id).await?;
let pick_disk = if let Some(hd) = host_disks.first() {
hd
} else {
bail!("No host disk found")
};
let nodes = api.list_nodes().await?;
let node = if let Some(n) = nodes.iter().find(|n| n.name == host.name) {
n
} else {
continue;
let mut new_vm = Vm {
host_id: pick_host.id,
user_id: user.id,
image_id: image.id,
template_id: template.id,
ssh_key_id: ssh_key.id,
created: Utc::now(),
expires: Utc::now(),
cpu: template.cpu,
memory: template.memory,
disk_size: template.disk_size,
disk_id: pick_disk.id,
..Default::default()
};
let new_id = self.db.insert_vm(&new_vm).await?;
new_vm.id = new_id;
Ok(new_vm)
}
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?;
// 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_MILLI_SATS: u64 = 100_000_000_000;
const INVOICE_EXPIRE: i64 = 3600;
let cost = cost_plan.amount
* match cost_plan.currency.as_str() {
"EUR" => 1_100_000, //TODO: rates
"BTC" => 1, // BTC amounts are always millisats
c => bail!("Unknown currency {c}"),
};
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?;
info!("Creating invoice for {vm_id} for {cost} mSats");
let mut lnd = self.lnd.clone();
let invoice = lnd
.lightning()
.add_invoice(Invoice {
memo: format!("VM renewal {vm_id} to {new_expire}"),
value_msat: cost as i64,
expiry: INVOICE_EXPIRE,
..Default::default()
})
.await?;
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,
});
}
let mut vm_payment = VmPayment {
id: 0,
vm_id,
created: Utc::now(),
expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE as u64)),
amount: cost,
invoice: invoice.into_inner().payment_request,
time_value: (new_expire - vm.expires).num_seconds() as u64,
is_paid: false,
};
let payment_id = self.db.insert_vm_payment(&vm_payment).await?;
vm_payment.id = payment_id;
bail!("Failed to create VM")
Ok(vm_payment)
}
}