Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
2 changes: 2 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions lnvps_api/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions lnvps_api_admin/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3019,6 +3019,22 @@ pub struct AdminCreateVmRequest {
pub reason: Option<String>,
}

/// 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<String>,
pub reason: Option<String>,
}

/// Request to import an existing host VM into the database (issue #166)
#[derive(Deserialize)]
pub struct AdminImportVmRequest {
Expand Down
86 changes: 82 additions & 4 deletions lnvps_api_admin/src/admin/vms.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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};
Expand All @@ -25,6 +25,7 @@ pub fn router() -> Router<RouterState> {
"/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}",
Expand Down Expand Up @@ -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<RouterState>,
Json(req): Json<AdminCreateCustomVmRequest>,
) -> ApiResult<JobResponse> {
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`,
Expand Down Expand Up @@ -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);
}
}
Loading
Loading