Skip to content

Commit 80b8a9a

Browse files
committed
feat(admin): import existing host VMs (#166)
Add admin capability to adopt VMs that exist on a host but aren't tracked in the database: - GET /api/admin/v1/hosts/{id}/vms/unmanaged - discover host VMs with no matching DB record - POST /api/admin/v1/hosts/{id}/vms/import - import one VM, assigned to a user and billed via the region's custom pricing (required), capturing the VM's live CPU/memory/disk specs into a custom template The admin service has no direct host access, so both operations dispatch a WorkJob to the worker; discovery replies over a temporary Redis pub/sub channel. Imported VMs use the fixed Proxmox id mapping (vmid = db_id + 100) so lifecycle operations target the correct host VM. Proxmox only. Fixes #166
1 parent 8b5097d commit 80b8a9a

15 files changed

Lines changed: 772 additions & 5 deletions

File tree

API_CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88

99
### Added
1010

11+
- **Import existing host VMs (admin)** — new admin endpoints to adopt VMs that exist on a host but aren't tracked in the database (issue #166).
12+
- `GET /api/admin/v1/hosts/{id}/vms/unmanaged` lists VMs present on the host with no matching database record (returns host vmid, mapped database id, name, CPU/memory/disk specs, storage, MAC, running state). The admin service dispatches a discovery job to the worker and reads the reply over a temporary Redis pub/sub channel.
13+
- `POST /api/admin/v1/hosts/{id}/vms/import` (`{ host_vm_id, user_id, reason? }`) imports one VM: it is assigned to `user_id` and billed via the region's **custom pricing** (required — import fails if the region has none), capturing the VM's current CPU/memory/disk specs into a custom template. Currently supports Proxmox hosts. Returns a `job_id`.
14+
1115
- **2026-07-18** - Passwordless WebAuthn / passkey login
1216
- New `fetch`-based endpoints `POST /api/v1/webauthn/register/start` + `/register/finish` (creates a passwordless account) and `POST /api/v1/webauthn/login/start` + `/login/finish` (usernameless / discoverable login). Each `start` returns a `challenge` for the browser `navigator.credentials` API plus an opaque signed `state`; the matching `finish` posts back that `state` with the credential and returns a `{ token, token_type, expires_in }` session response.
1317
- Uses the same stateless session **JWT** as OAuth (`Authorization: Bearer <jwt>`), so passkey users reach every existing authenticated endpoint. Configured under a new `webauthn` config section (`rp-id`, `rp-origin`, `rp-name`); the signing secret lives in the shared `session` block (below).

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lnvps_api/src/host/dummy_host.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::host::{
55
use async_trait::async_trait;
66
use chrono::Utc;
77
use lnvps_api_common::retry::OpResult;
8-
use lnvps_api_common::{GB, PB, TB, VmRunningState, VmRunningStates, op_fatal};
8+
use lnvps_api_common::{GB, HostVmSpec, PB, TB, VmRunningState, VmRunningStates, op_fatal};
99
use lnvps_db::{Vm, VmOsImage};
1010
use serde::{Deserialize, Serialize};
1111
use std::collections::HashMap;
@@ -152,10 +152,27 @@ impl DummyVmHost {
152152
let _ = std::fs::write(STATE_FILE, json);
153153
}
154154
}
155+
156+
/// Set the list of host VMs reported by [`list_host_vms`].
157+
///
158+
/// Backed by a process-wide registry so the value survives the fresh
159+
/// [`DummyVmHost`] instances that `get_host_client` constructs. Intended for
160+
/// exercising VM import/discovery flows.
161+
pub async fn set_host_vms(vms: Vec<HostVmSpec>) {
162+
*DUMMY_HOST_VMS.lock().await = vms;
163+
}
155164
}
156165

166+
/// Process-wide registry of "host" VMs reported by [`DummyVmHost::list_host_vms`].
167+
static DUMMY_HOST_VMS: LazyLock<Arc<Mutex<Vec<HostVmSpec>>>> =
168+
LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
169+
157170
#[async_trait]
158171
impl VmHostClient for DummyVmHost {
172+
async fn list_host_vms(&self) -> OpResult<Vec<HostVmSpec>> {
173+
Ok(DUMMY_HOST_VMS.lock().await.clone())
174+
}
175+
159176
async fn get_info(&self) -> OpResult<VmHostInfo> {
160177
Ok(VmHostInfo {
161178
cpu: 100,

lnvps_api/src/host/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::settings::ProvisionerConfig;
22
use anyhow::{Result, anyhow, bail};
33
use async_trait::async_trait;
44
use futures::future::join_all;
5+
use lnvps_api_common::HostVmSpec;
56
use lnvps_api_common::VmRunningState;
67
use lnvps_api_common::retry::OpResult;
78
use lnvps_db::{
@@ -30,6 +31,18 @@ pub struct TerminalStream {
3031
pub trait VmHostClient: Send + Sync {
3132
async fn get_info(&self) -> OpResult<VmHostInfo>;
3233

34+
/// List all VMs present on the host, described in host-native terms.
35+
///
36+
/// Used for discovering/importing VMs that exist on the host but aren't
37+
/// tracked in the database. Defaults to unsupported for hosts that don't
38+
/// implement discovery.
39+
async fn list_host_vms(&self) -> OpResult<Vec<HostVmSpec>> {
40+
use lnvps_api_common::retry::OpError;
41+
Err(OpError::Fatal(anyhow!(
42+
"VM discovery is not supported on this host type"
43+
)))
44+
}
45+
3346
/// Download OS image to the host
3447
async fn download_os_image(&self, image: &VmOsImage) -> OpResult<()>;
3548

lnvps_api/src/host/proxmox.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use anyhow::Result;
88
use async_trait::async_trait;
99
use chrono::Utc;
1010
use ipnetwork::IpNetwork;
11+
use lnvps_api_common::HostVmSpec;
1112
use lnvps_api_common::JsonApi;
1213
use lnvps_api_common::retry::{OpError, OpResult, Pipeline, RetryPolicy};
1314
use lnvps_api_common::{VmRunningState, VmRunningStates, op_fatal, parse_gateway};
@@ -1279,6 +1280,51 @@ impl VmHostClient for ProxmoxClient {
12791280
}
12801281
}
12811282

1283+
async fn list_host_vms(&self) -> OpResult<Vec<HostVmSpec>> {
1284+
let vms = self.list_vms(&self.node).await?;
1285+
let mut out = Vec::with_capacity(vms.len());
1286+
for vm in vms {
1287+
// Map to the LNVPS db id (vmid = db_id + 100). VMs with vmid < 100
1288+
// fall outside the managed range and can't be imported.
1289+
let mapped_vm_id = if vm.vm_id >= 100 {
1290+
let id: ProxmoxVmId = vm.vm_id.into();
1291+
Some(id.inner())
1292+
} else {
1293+
None
1294+
};
1295+
1296+
// Pull the live config for MAC + backing storage; tolerate failures
1297+
// so a single unreadable VM doesn't abort discovery.
1298+
let (mac_address, disk_storage) =
1299+
match self.get_vm_config(&self.node, vm.vm_id.into()).await {
1300+
Ok(cfg) => (
1301+
cfg.config.net.as_deref().and_then(parse_mac_from_net),
1302+
cfg.config
1303+
.scsi_0
1304+
.as_deref()
1305+
.and_then(parse_storage_from_disk),
1306+
),
1307+
Err(e) => {
1308+
warn!("Failed to read config for vm {}: {}", vm.vm_id, e);
1309+
(None, None)
1310+
}
1311+
};
1312+
1313+
out.push(HostVmSpec {
1314+
host_vm_id: vm.vm_id as i64,
1315+
mapped_vm_id,
1316+
name: vm.name.clone(),
1317+
cpu: vm.cpus.unwrap_or(0),
1318+
memory: vm.max_mem.unwrap_or(0),
1319+
disk_size: vm.max_disk.unwrap_or(0),
1320+
disk_storage,
1321+
mac_address,
1322+
running: matches!(vm.status, VmStatus::Running),
1323+
});
1324+
}
1325+
Ok(out)
1326+
}
1327+
12821328
async fn download_os_image(&self, image: &VmOsImage) -> OpResult<()> {
12831329
let iso_storage = self.get_iso_storage(&self.node).await?;
12841330
let files = self.list_storage_files(&self.node, &iso_storage).await?;
@@ -1973,6 +2019,39 @@ impl ProxmoxClient {
19732019
#[derive(Debug, Copy, Clone, Default)]
19742020
pub struct ProxmoxVmId(u64);
19752021

2022+
impl ProxmoxVmId {
2023+
/// The underlying LNVPS database id (host vmid minus the +100 offset).
2024+
pub fn inner(&self) -> u64 {
2025+
self.0
2026+
}
2027+
}
2028+
2029+
/// Extract the MAC address from a Proxmox `netN` config string, e.g.
2030+
/// `virtio=BC:24:11:00:11:22,bridge=vmbr0,firewall=1` -> `BC:24:11:00:11:22`.
2031+
fn parse_mac_from_net(net: &str) -> Option<String> {
2032+
net.split(',').find_map(|kv| {
2033+
let (k, v) = kv.split_once('=')?;
2034+
// The NIC model key (virtio/e1000/...) holds the MAC as its value.
2035+
if v.split(':').count() == 6 && k != "bridge" {
2036+
Some(v.to_string())
2037+
} else {
2038+
None
2039+
}
2040+
})
2041+
}
2042+
2043+
/// Extract the storage pool from a Proxmox disk config string, e.g.
2044+
/// `local-lvm:vm-1566-disk-0,size=32G` -> `local-lvm`.
2045+
fn parse_storage_from_disk(disk: &str) -> Option<String> {
2046+
let first = disk.split(',').next()?;
2047+
let (storage, _) = first.split_once(':')?;
2048+
if storage.is_empty() {
2049+
None
2050+
} else {
2051+
Some(storage.to_string())
2052+
}
2053+
}
2054+
19762055
impl From<ProxmoxVmId> for i32 {
19772056
fn from(val: ProxmoxVmId) -> Self {
19782057
val.0 as i32 + 100
@@ -2865,6 +2944,46 @@ mod tests {
28652944
assert!(!json.contains("proto"), "json: {json}");
28662945
}
28672946

2947+
#[test]
2948+
fn test_parse_mac_from_net() {
2949+
assert_eq!(
2950+
parse_mac_from_net("virtio=BC:24:11:00:11:22,bridge=vmbr0,firewall=1").as_deref(),
2951+
Some("BC:24:11:00:11:22")
2952+
);
2953+
assert_eq!(
2954+
parse_mac_from_net("e1000=00:15:5D:01:02:03,bridge=vmbr1").as_deref(),
2955+
Some("00:15:5D:01:02:03")
2956+
);
2957+
// No MAC present
2958+
assert_eq!(parse_mac_from_net("bridge=vmbr0,firewall=1"), None);
2959+
assert_eq!(parse_mac_from_net(""), None);
2960+
}
2961+
2962+
#[test]
2963+
fn test_parse_storage_from_disk() {
2964+
assert_eq!(
2965+
parse_storage_from_disk("local-lvm:vm-1566-disk-0,size=32G").as_deref(),
2966+
Some("local-lvm")
2967+
);
2968+
assert_eq!(
2969+
parse_storage_from_disk("ceph:vm-100-disk-0").as_deref(),
2970+
Some("ceph")
2971+
);
2972+
// No storage separator
2973+
assert_eq!(parse_storage_from_disk("none"), None);
2974+
assert_eq!(parse_storage_from_disk(""), None);
2975+
}
2976+
2977+
#[test]
2978+
fn test_proxmox_vm_id_inner_maps_to_db_id() {
2979+
// Host vmid 1566 -> db id 1466
2980+
let id: ProxmoxVmId = 1566i32.into();
2981+
assert_eq!(id.inner(), 1466);
2982+
// Round trip back to host vmid
2983+
let host_id: i32 = id.into();
2984+
assert_eq!(host_id, 1566);
2985+
}
2986+
28682987
#[test]
28692988
fn test_to_pve_firewall_rule_reject_action() {
28702989
let rule = lnvps_db::VmFirewallRule {

0 commit comments

Comments
 (0)