diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 4945699c..1a6193c8 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -353,6 +353,77 @@ Response: returns immediately with a job ID. The VM creation is handled by the provisioner and includes full audit logging with admin action metadata. +#### Create Custom-Spec VM for User + +``` +POST /api/admin/v1/vms/custom +``` + +Required Permission: `virtual_machines::create` + +Creates a VM from an arbitrary spec (rather than a fixed template) for a specific user, billed against a custom pricing +plan. Same spec fields as the customer endpoint `POST /api/v1/vm/custom-template`; the region comes from `pricing_id`. +Processed asynchronously via the work job system. + +Body: + +```json +{ + "user_id": number, + // Required - Target user ID + "pricing_id": number, + // Required - Custom pricing plan ID (decides region and currency) + "cpu": number, + // Required - vCPU cores + "memory": number, + // Required - Memory in bytes + "disk": number, + // Required - Disk size in bytes + "disk_type": "hdd" | "ssd", + // Required + "disk_interface": "sata" | "scsi" | "pcie", + // Required + "cpu_mfg": "string", + // Optional - e.g. "intel", "amd"; omitted means any + "cpu_arch": "string", + // Optional - e.g. "x86_64", "arm64"; omitted means any + "cpu_feature": ["string"], + // Optional - required CPU features, e.g. ["AVX2"]; empty means any + "image_id": number, + // Required - OS image ID + "ssh_key_id": number, + // Required - SSH key ID (must belong to user) + "ref_code": "string", + // Optional - Referral code + "reason": "string" + // Optional - Admin reason for audit trail +} +``` + +Response: + +```json +{ + "data": { + "job_id": "stream-id-12345" + } +} +``` + +**Validation:** + +- Unknown `disk_type`, `disk_interface`, `cpu_mfg`, `cpu_arch` or `cpu_feature` values are rejected with `400` before any + lookup — they are never defaulted +- Unknown *keys* are ignored, not rejected: a misspelled optional field (e.g. `cpu_ar` instead of `cpu_arch`) is dropped + and that field falls back to "any". Check spelling of optional fields +- User, custom pricing plan and image must exist +- SSH key must exist and belong to the specified user +- Spec range limits, pricing enabled/expiry, image architecture compatibility and host capacity are enforced when the job + runs (same checks as a customer custom order), so those failures surface on the job, not the request + +**Asynchronous Processing:** dispatches a `CreateCustomVm` work job. Like the template path, the VM is created unpaid +(expired) and logged with admin action metadata. + #### Transfer VM ``` diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 7f921dcf..61b10966 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Admin can create a custom-spec VM** (issue #335) — `POST /api/admin/v1/vms/custom` creates a VM from an arbitrary spec for any user, billed against a custom pricing plan, instead of only from the fixed template catalog. Body is the customer custom-order shape plus `user_id`/`reason`: `pricing_id` (also decides the region), `cpu`, `memory` and `disk` in bytes, `disk_type`, `disk_interface`, optional `cpu_mfg`/`cpu_arch`/`cpu_feature`, `image_id`, `ssh_key_id` (must belong to the target user), optional `ref_code`. Returns `{ "job_id": string }`; the VM is created unpaid like a customer order. Unknown enum spellings are `400` and are never defaulted. Additive: `POST /api/admin/v1/vms` (template path) is unchanged. + - **`host_ssh_keys` on VM status** (issue #154) — `GET /api/v1/vm/{id}` and `GET /api/v1/vm` now return the VM's own SSH host keys as `[{ key_type, public_key, fingerprint_sha256 }]`, so a client can verify the host on first connect instead of accepting whatever key answers. The list is empty until the keys are captured (scanned from the host once the VM is running, public keys only) and is re-captured after a reinstall, which regenerates them. Additive: no existing field changed. Not to be confused with `ssh_key`, which is the customer's authorized key. ### Added diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index baa37cca..b3ad86f5 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -2817,6 +2817,50 @@ impl Worker { vm.id, user_id ))); } + WorkJob::CreateCustomVm { + user_id, + spec, + image_id, + ssh_key_id, + ref_code, + admin_user_id, + reason, + } => { + info!( + "Admin {} creating custom VM for user {}", + admin_user_id, user_id + ); + let template = spec.to_template()?; + + let provisioner = self.subscription_handler.vm_provisioner(); + let vm = provisioner + .provision_custom(*user_id, template, *image_id, *ssh_key_id, ref_code.clone()) + .await?; + + let metadata = Some(serde_json::json!({ + "admin_user_id": admin_user_id, + "admin_action": true, + "reason": reason + })); + + if let Err(e) = self + .vm_history_logger + .log_vm_created(&vm, Some(*user_id), metadata) + .await + { + error!("Failed to log VM {} creation: {}", vm.id, e); + } + + info!( + "Admin {} successfully created custom VM {} for user {}", + admin_user_id, vm.id, user_id + ); + + return Ok(Some(format!( + "Custom VM {} created successfully for user {}", + vm.id, user_id + ))); + } WorkJob::ListUnmanagedVms { host_id, reply_channel, diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 847840a2..36e2480c 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -3019,6 +3019,22 @@ pub struct AdminCreateVmRequest { pub reason: Option, } +/// Create a VM from a custom spec, priced against a custom pricing plan. +/// +/// Mirrors the customer custom-order body: the region comes from `pricing_id`, +/// and `ssh_key_id` must be one of the target user's own keys. +#[derive(Deserialize)] +pub struct AdminCreateCustomVmRequest { + pub user_id: u64, + /// Spec fields are flattened into this body, matching the customer order shape. + #[serde(flatten)] + pub spec: lnvps_api_common::CustomVmSpec, + pub image_id: u64, + pub ssh_key_id: u64, + pub ref_code: Option, + pub reason: Option, +} + /// Request to import an existing host VM into the database (issue #166) #[derive(Deserialize)] pub struct AdminImportVmRequest { diff --git a/lnvps_api_admin/src/admin/vms.rs b/lnvps_api_admin/src/admin/vms.rs index 30b9783f..aac14e55 100644 --- a/lnvps_api_admin/src/admin/vms.rs +++ b/lnvps_api_admin/src/admin/vms.rs @@ -1,8 +1,8 @@ use crate::admin::RouterState; use crate::admin::auth::AdminAuth; use crate::admin::model::{ - AdminCreateVmRequest, AdminPaymentRefundsInfo, AdminRefundAmountInfo, AdminVmHistoryInfo, - AdminVmInfo, AdminVmPaymentInfo, JobResponse, + AdminCreateCustomVmRequest, AdminCreateVmRequest, AdminPaymentRefundsInfo, + AdminRefundAmountInfo, AdminVmHistoryInfo, AdminVmInfo, AdminVmPaymentInfo, JobResponse, }; use axum::extract::{Path, Query, State}; use axum::routing::{get, post, put}; @@ -11,8 +11,8 @@ use chrono::{DateTime, Days, Utc}; use lightning_invoice::Bolt11Invoice; use lnvps_api_common::{ ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, PricingEngine, - UpgradeConfig, VatClient, VmHistoryLogger, VmRunningState, VmStateCache, WorkJob, - RefundEvidence, build_refund_row, refundable_remaining, + RefundEvidence, UpgradeConfig, VatClient, VmHistoryLogger, VmRunningState, VmStateCache, + WorkJob, build_refund_row, refundable_remaining, }; use lnvps_db::{AdminAction, AdminResource, SubscriptionPaymentType}; use log::{error, info}; @@ -25,6 +25,7 @@ pub fn router() -> Router { "/api/admin/v1/vms", get(admin_list_vms).post(admin_create_vm), ) + .route("/api/admin/v1/vms/custom", post(admin_create_custom_vm)) .route("/api/admin/v1/vms/extend-all", post(admin_extend_all_vms)) .route( "/api/admin/v1/vms/{id}", @@ -1271,6 +1272,58 @@ async fn admin_create_vm( } } +/// Create a VM from a custom spec for a specific user (admin action). +/// +/// The spec, pricing and capacity checks all live in `provision_custom`, which +/// the worker calls; what is validated here is only what makes an obviously bad +/// request a 4xx instead of a failed job. +async fn admin_create_custom_vm( + auth: AdminAuth, + State(this): State, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::VirtualMachines, AdminAction::Create)?; + + // Reject unknown enum spellings before any lookup, so a typo is a 400 + // rather than a job that fails out of band. + req.spec + .to_template() + .map_err(|e| ApiError::bad_request(format!("Invalid VM spec: {e}")))?; + + let _user = this.db.get_user(req.user_id).await?; + let _pricing = this.db.get_custom_pricing(req.spec.pricing_id).await?; + let _image = this.db.get_os_image(req.image_id).await?; + + let ssh_key = this.db.get_user_ssh_key(req.ssh_key_id).await?; + if ssh_key.user_id != req.user_id { + return ApiData::err("SSH key does not belong to the specified user"); + } + + let create_job = WorkJob::CreateCustomVm { + user_id: req.user_id, + spec: req.spec, + image_id: req.image_id, + ssh_key_id: req.ssh_key_id, + ref_code: req.ref_code, + admin_user_id: auth.user_id, + reason: req.reason, + }; + + match this.work_commander.send(create_job).await { + Ok(stream_id) => { + info!( + "Custom VM creation job queued with stream ID: {}", + stream_id + ); + ApiData::ok(JobResponse { job_id: stream_id }) + } + Err(e) => { + error!("Failed to queue custom VM creation job: {}", e); + ApiData::err("Failed to queue custom VM creation job") + } + } +} + /// Manually mark a VM payment as paid (admin override). /// /// This calls `subscription_payment_paid` which atomically sets `is_paid=true`, @@ -1366,4 +1419,29 @@ mod tests { assert_eq!(req.disabled, Some(true)); assert_eq!(req.admin_notes, Some(Some("x".to_string()))); } + + /// The spec is flattened, so the body stays the same shape the customer + /// custom-order endpoint accepts. + #[test] + fn admin_create_custom_vm_request_spec_is_flat() { + let req: AdminCreateCustomVmRequest = serde_json::from_str( + r#"{"user_id": 1, "pricing_id": 2, "cpu": 4, "memory": 8589934592, + "disk": 107374182400, "disk_type": "ssd", "disk_interface": "pcie", + "cpu_arch": "x86_64", "image_id": 3, "ssh_key_id": 4}"#, + ) + .unwrap(); + assert_eq!(req.user_id, 1); + assert_eq!(req.spec.pricing_id, 2); + assert_eq!(req.spec.cpu, 4); + assert_eq!(req.spec.disk_type, "ssd"); + assert_eq!(req.spec.cpu_arch.as_deref(), Some("x86_64")); + assert!(req.spec.cpu_feature.is_empty()); + assert_eq!(req.image_id, 3); + assert_eq!(req.ssh_key_id, 4); + assert!(req.ref_code.is_none()); + + let t = req.spec.to_template().unwrap(); + assert_eq!(t.pricing_id, 2); + assert_eq!(t.disk_type, lnvps_db::DiskType::SSD); + } } diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index 92d102e7..97fb92bf 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -738,6 +738,68 @@ impl From for ApiPrice { } } +/// A custom VM spec as it travels over the wire: enum fields are the same +/// strings the customer order API accepts, because neither the work queue nor a +/// JSON body can carry the database enums directly. +/// +/// The region is not part of the spec — it comes from `pricing_id`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomVmSpec { + pub pricing_id: u64, + pub cpu: u16, + /// Memory in bytes + pub memory: u64, + /// Disk size in bytes + pub disk: u64, + /// "hdd" or "ssd" + pub disk_type: String, + /// "sata", "scsi" or "pcie" + pub disk_interface: String, + /// CPU manufacturer (e.g. "intel", "amd"); `None` means any + pub cpu_mfg: Option, + /// CPU architecture (e.g. "x86_64", "arm64"); `None` means any + pub cpu_arch: Option, + #[serde(default)] + pub cpu_feature: Vec, +} + +impl CustomVmSpec { + /// Build the template this spec describes. + /// + /// An unknown enum spelling is an error rather than a default: a silently + /// downgraded disk or architecture is worse than a rejected request. + pub fn to_template(&self) -> Result { + let mut cpu_features = Vec::with_capacity(self.cpu_feature.len()); + for f in &self.cpu_feature { + cpu_features + .push(CpuFeature::from_str(f).map_err(|_| anyhow!("unknown cpu feature {}", f))?); + } + Ok(VmCustomTemplate { + id: 0, + cpu: self.cpu, + memory: self.memory, + disk_size: self.disk, + disk_type: lnvps_db::DiskType::from_str(&self.disk_type)?, + disk_interface: lnvps_db::DiskInterface::from_str(&self.disk_interface)?, + pricing_id: self.pricing_id, + cpu_mfg: match &self.cpu_mfg { + Some(v) => { + CpuMfg::from_str(v).map_err(|_| anyhow!("unknown cpu manufacturer {}", v))? + } + None => CpuMfg::default(), + }, + cpu_arch: match &self.cpu_arch { + Some(v) => { + CpuArch::from_str(v).map_err(|_| anyhow!("unknown cpu architecture {}", v))? + } + None => CpuArch::default(), + }, + cpu_features: cpu_features.into(), + ..Default::default() + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpgradeConfig { pub new_cpu: Option, @@ -929,6 +991,78 @@ mod tests { } } + fn custom_spec() -> CustomVmSpec { + CustomVmSpec { + pricing_id: 7, + cpu: 4, + memory: 8 * 1024 * 1024 * 1024, + disk: 100 * 1024 * 1024 * 1024, + disk_type: "ssd".to_string(), + disk_interface: "pcie".to_string(), + cpu_mfg: None, + cpu_arch: None, + cpu_feature: vec![], + } + } + + #[test] + fn test_custom_vm_spec_to_template() { + let spec = CustomVmSpec { + cpu_mfg: Some("amd".to_string()), + cpu_arch: Some("x86_64".to_string()), + cpu_feature: vec!["AVX2".to_string()], + ..custom_spec() + }; + let t = spec.to_template().unwrap(); + assert_eq!(t.id, 0); + assert_eq!(t.pricing_id, 7); + assert_eq!(t.cpu, 4); + assert_eq!(t.memory, 8 * 1024 * 1024 * 1024); + assert_eq!(t.disk_size, 100 * 1024 * 1024 * 1024); + assert_eq!(t.disk_type, lnvps_db::DiskType::SSD); + assert_eq!(t.disk_interface, lnvps_db::DiskInterface::PCIe); + assert_eq!(t.cpu_mfg, CpuMfg::Amd); + assert_eq!(t.cpu_arch, CpuArch::X86_64); + assert_eq!(t.cpu_features.0, vec![CpuFeature::AVX2]); + } + + #[test] + fn test_custom_vm_spec_omitted_cpu_fields_mean_any() { + let t = custom_spec().to_template().unwrap(); + assert_eq!(t.cpu_mfg, CpuMfg::default()); + assert_eq!(t.cpu_arch, CpuArch::default()); + assert!(t.cpu_features.0.is_empty()); + } + + #[test] + fn test_custom_vm_spec_rejects_unknown_enum_values() { + // Never silently default: a typo must not become a different machine. + for spec in [ + CustomVmSpec { + disk_type: "nvme".to_string(), + ..custom_spec() + }, + CustomVmSpec { + disk_interface: "ide".to_string(), + ..custom_spec() + }, + CustomVmSpec { + cpu_mfg: Some("acme".to_string()), + ..custom_spec() + }, + CustomVmSpec { + cpu_arch: Some("sparc".to_string()), + ..custom_spec() + }, + CustomVmSpec { + cpu_feature: vec!["TELEPATHY".to_string()], + ..custom_spec() + }, + ] { + assert!(spec.to_template().is_err()); + } + } + #[test] fn test_vps_serialization_includes_type_tag() { let res = ApiSubscriptionLineItemResource::Vps { vm_id: 1 }; diff --git a/lnvps_api_common/src/work/mod.rs b/lnvps_api_common/src/work/mod.rs index e230ba5c..dfaf5060 100644 --- a/lnvps_api_common/src/work/mod.rs +++ b/lnvps_api_common/src/work/mod.rs @@ -1,4 +1,4 @@ -use crate::model::UpgradeConfig; +use crate::model::{CustomVmSpec, UpgradeConfig}; use anyhow::Result; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -169,6 +169,16 @@ pub enum WorkJob { admin_user_id: u64, reason: Option, }, + /// Create a VM from a custom spec for a specific user (admin action). + CreateCustomVm { + user_id: u64, + spec: CustomVmSpec, + image_id: u64, + ssh_key_id: u64, + ref_code: Option, + admin_user_id: u64, + reason: Option, + }, /// Send an email verification link to the user SendEmailVerification { user_id: u64, verify_url: String }, /// Download OS images to all hosts, verifying checksums and re-downloading if stale. @@ -289,6 +299,7 @@ impl fmt::Display for WorkJob { WorkJob::ReinstallVm { .. } => write!(f, "ReinstallVm"), WorkJob::ImportVm { .. } => write!(f, "ImportVm"), WorkJob::CreateVm { .. } => write!(f, "CreateVm"), + WorkJob::CreateCustomVm { .. } => write!(f, "CreateCustomVm"), WorkJob::SendEmailVerification { .. } => write!(f, "SendEmailVerification"), WorkJob::DownloadOsImages { .. } => write!(f, "DownloadOsImages"), WorkJob::CheckSubscriptions => write!(f, "CheckSubscriptions"), diff --git a/lnvps_e2e/src/admin_api.rs b/lnvps_e2e/src/admin_api.rs index 27ea4ce5..23a5259a 100644 --- a/lnvps_e2e/src/admin_api.rs +++ b/lnvps_e2e/src/admin_api.rs @@ -269,6 +269,63 @@ mod tests { assert!(data.data.is_empty() || data.data[0]["id"].is_u64()); } + #[tokio::test] + async fn test_admin_create_custom_vm_requires_auth() { + let client = admin_client_no_auth(); + let body = serde_json::json!({ + "user_id": 1, + "pricing_id": 1, + "cpu": 2, + "memory": 2147483648_u64, + "disk": 21474836480_u64, + "disk_type": "ssd", + "disk_interface": "pcie", + "image_id": 1, + "ssh_key_id": 1 + }); + let resp = client + .post("/api/admin/v1/vms/custom", &body) + .await + .unwrap(); + assert!( + resp.status() == StatusCode::FORBIDDEN || resp.status() == StatusCode::UNAUTHORIZED, + "unauthenticated custom VM creation must not be accepted, got {}", + resp.status() + ); + } + + #[tokio::test] + async fn test_admin_create_custom_vm_rejects_unknown_spec_values() { + let client = setup().await; + for (field, value) in [ + ("disk_type", "nvme"), + ("disk_interface", "ide"), + ("cpu_arch", "sparc"), + ] { + let mut body = serde_json::json!({ + "user_id": 1, + "pricing_id": 1, + "cpu": 2, + "memory": 2147483648_u64, + "disk": 21474836480_u64, + "disk_type": "ssd", + "disk_interface": "pcie", + "image_id": 1, + "ssh_key_id": 1 + }); + body[field] = serde_json::json!(value); + let resp = client + .post_auth("/api/admin/v1/vms/custom", &body) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "{field}={value} must be rejected before any lookup" + ); + } + } + #[tokio::test] async fn test_admin_list_vms_with_pagination() { let client = setup().await; diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index bd932df2..750637e1 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -1645,6 +1645,89 @@ mod tests { ); } + // ---------------------------------------------------------------- + // 19b. Admin custom-spec VM creation + // ---------------------------------------------------------------- + let owner_id = json_ok( + admin + .get_auth(&format!("/api/admin/v1/vms/{vm_id}")) + .await + .unwrap(), + ) + .await["data"]["user_id"] + .as_u64() + .unwrap(); + + // Every VM this user already has, so the assertion below cannot be + // satisfied by the customer's own order from step 19. + let list_path = format!("/api/admin/v1/vms?user_id={owner_id}&limit=50"); + let known_vm_ids: std::collections::HashSet = + json_ok(admin.get_auth(&list_path).await.unwrap()).await["data"] + .as_array() + .unwrap() + .iter() + .filter_map(|vm| vm["id"].as_u64()) + .collect(); + + let body = serde_json::json!({ + "user_id": owner_id, + "pricing_id": custom_pricing_id, + "cpu": 1, + "memory": 1073741824_u64, + "disk": 10737418240_u64, + "disk_type": "ssd", + "disk_interface": "pcie", + "image_id": image_id, + "ssh_key_id": ssh_key_id, + "reason": "e2e admin custom create" + }); + let admin_custom = json_ok( + admin + .post_auth("/api/admin/v1/vms/custom", &body) + .await + .unwrap(), + ) + .await; + assert!( + !admin_custom["data"]["job_id"].as_str().unwrap().is_empty(), + "admin custom VM creation should return a job id" + ); + + // The job is async: wait for a custom-template VM that did not exist + // before the admin call. + let appeared = poll_until(30, 500, || { + let admin = admin.clone(); + let list_path = list_path.clone(); + let known = known_vm_ids.clone(); + async move { + admin_custom_vm_ids(&admin, &list_path, &known) + .await + .is_some() + } + }) + .await; + assert!( + appeared, + "admin-created custom VM did not appear for user {owner_id}" + ); + let admin_custom_vm_id = admin_custom_vm_ids(&admin, &list_path, &known_vm_ids) + .await + .unwrap(); + eprintln!("Admin created custom VM {admin_custom_vm_id} for user {owner_id}"); + + // A bad spec value is refused outright, not queued. + let mut bad = body.clone(); + bad["disk_type"] = serde_json::json!("nvme"); + let resp = admin + .post_auth("/api/admin/v1/vms/custom", &bad) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "unknown disk_type must be rejected" + ); + // ---------------------------------------------------------------- // 20. Cleanup: hard-delete VMs and all infrastructure via DB // The worker cannot reach fake hosts, so API-level VM deletion @@ -1663,6 +1746,10 @@ mod tests { crate::db::hard_delete_vm(&pool, cvm_id).await.unwrap(); eprintln!("Hard-deleted custom VM {cvm_id}"); } + crate::db::hard_delete_vm(&pool, admin_custom_vm_id) + .await + .unwrap(); + eprintln!("Hard-deleted admin custom VM {admin_custom_vm_id}"); crate::db::hard_delete_referral(&pool, referral_id) .await .unwrap(); @@ -2250,6 +2337,26 @@ mod tests { eprintln!("[cleanup] Infrastructure hard-deleted ✓"); } + // ---------------------------------------------------------------- + // Find a custom-template VM in the admin VM list that isn't the one the + // customer ordered themselves, i.e. the admin-created one. + // ---------------------------------------------------------------- + async fn admin_custom_vm_ids( + admin: &crate::client::TestClient, + list_path: &str, + known: &std::collections::HashSet, + ) -> Option { + let resp = admin.get_auth(list_path).await.ok()?; + let body = serde_json::from_str::(&resp.text().await.ok()?).ok()?; + body["data"].as_array()?.iter().find_map(|vm| { + let id = vm["id"].as_u64()?; + if known.contains(&id) || vm["custom_template_id"].as_u64().is_none() { + return None; + } + Some(id) + }) + } + // ---------------------------------------------------------------- // Poll helper: retry a condition up to `max_secs` seconds, // checking every `interval_ms` milliseconds.