diff --git a/backend/app.py b/backend/app.py index 3ccbb56..a751001 100644 --- a/backend/app.py +++ b/backend/app.py @@ -22,11 +22,12 @@ from models import ( CleanupResponse, AnalyzeRequest, AnalyzeResponse, + CloneRequest, HealthResponse, ErrorResponse, ) from converter import extract_if_needed, discover_disk, detect_os, detect_efi -from provisioner import submit_job, get_job_status, cleanup_staging +from provisioner import submit_job, get_job_status, cleanup_staging, clone_vm # --------------------------------------------------------------------------- # Logging @@ -170,6 +171,17 @@ def cleanup_job(job_id: str, req: CleanupRequest) -> CleanupResponse: ) +@app.post(f"{API_PREFIX}/clone", response_model=JobStatusResponse, status_code=202) +def clone_vm_endpoint(req: CloneRequest) -> JobStatusResponse: + """Clone an existing VM with a new ID and name.""" + logger.info("Clone request: %d → %d (%s)", req.source_vmid, req.target_vmid, req.target_name) + try: + return clone_vm(req) + except Exception as exc: + logger.error("Clone failed: %s", exc) + raise HTTPException(status_code=400, detail=str(exc)) + + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- diff --git a/backend/models.py b/backend/models.py index 9e6dfff..744c614 100644 --- a/backend/models.py +++ b/backend/models.py @@ -110,3 +110,8 @@ class CleanupResponse(BaseModel): job_id: str action_taken: str # "purged" | "retained" message: str + +class CloneRequest(BaseModel): + source_vmid: int + target_vmid: int + target_name: str diff --git a/backend/provisioner.py b/backend/provisioner.py index f413308..d983329 100644 --- a/backend/provisioner.py +++ b/backend/provisioner.py @@ -21,6 +21,7 @@ from typing import Optional from models import ( JobSubmissionRequest, JobStatusResponse, + CloneRequest, JobStatus, DiskType, BootType, @@ -88,6 +89,34 @@ def get_job_status(job_id: str) -> JobStatusResponse: ) +def clone_vm(req: CloneRequest) -> JobStatusResponse: + """qm clone — instant full clone of an existing VM.""" + safe_name = re.sub(r'[^a-zA-Z0-9-]', '-', req.target_name).strip('-').lower() + if not safe_name: + safe_name = f"vm-{req.target_vmid}" + + job_id = f"clone_{req.source_vmid}_{req.target_vmid}_{int(time.time())}" + logger.info("Cloning VM %d → %d (%s)", req.source_vmid, req.target_vmid, safe_name) + + try: + _run(["qm", "clone", str(req.source_vmid), str(req.target_vmid), + "--name", safe_name, "--full"], timeout=600) + except Exception as exc: + logger.error("Clone failed: %s", exc) + return JobStatusResponse( + job_id=job_id, vmid=req.target_vmid, + status=JobStatus.FAILED, progress_percentage=0, + message="Clone failed", error_details=str(exc), + ) + + logger.info("Clone complete: %d → %d", req.source_vmid, req.target_vmid) + return JobStatusResponse( + job_id=job_id, vmid=req.target_vmid, + status=JobStatus.COMPLETED, progress_percentage=100, + message=f"VM {req.target_vmid} cloned from {req.source_vmid}", + ) + + def cleanup_staging(vmid: int, delete: bool) -> dict: """Remove staging files for a VM ID.""" out_dir = STAGING_OUT / str(vmid) diff --git a/frontend/api_client.py b/frontend/api_client.py index 4425bd5..91a1930 100644 --- a/frontend/api_client.py +++ b/frontend/api_client.py @@ -63,6 +63,14 @@ class ApiClient: """POST /api/v1/jobs/{job_id}/cleanup — remove or keep staging files.""" return self._post(f"/api/v1/jobs/{job_id}/cleanup", {"delete_staging_files": delete}) + def clone_vm(self, source_vmid: int, target_vmid: int, target_name: str) -> dict: + """POST /api/v1/clone — clone an existing VM.""" + return self._post("/api/v1/clone", { + "source_vmid": source_vmid, + "target_vmid": target_vmid, + "target_name": target_name, + }) + # ------------------------------------------------------------------ # Internal # ------------------------------------------------------------------ diff --git a/frontend/app.py b/frontend/app.py index b610df8..9746886 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -500,6 +500,21 @@ async def session_cleanup(job_id: str, request: Request): return JSONResponse({"error": exc.detail}, status_code=502) +@app.post("/session/clone") +async def session_clone(request: Request): + """Proxy clone request to backend.""" + try: + body = await request.json() + result = api.clone_vm( + source_vmid=body["source_vmid"], + target_vmid=body["target_vmid"], + target_name=body["target_name"], + ) + return result + except ApiError as exc: + return JSONResponse({"error": exc.detail}, status_code=502) + + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 485177e..be5489f 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -295,10 +295,18 @@ function initPolling() { let completed = false; // Reuse buttons - document.getElementById('btn-reuse-yes').onclick = () => { - document.getElementById('reuse-form-wrap').classList.remove('hidden'); + const prevVmid = parseInt(vmid) || 21000; + const srcFilename = sourceFilename || ''; + + document.getElementById('reuse-vmid').value = prevVmid + 1; + document.getElementById('reuse-vmname').value = (srcFilename.split('.')[0] || 'vm') + '-2'; + + document.getElementById('btn-clone').onclick = () => cloneVM(jobId, vmid); + document.getElementById('btn-copy').onclick = () => { + document.getElementById('copy-options').classList.remove('hidden'); }; - document.getElementById('btn-reuse-no').onclick = () => reuseImage(jobId, vmid, sourceFilename, false); + document.getElementById('btn-cleanup').onclick = () => reuseImage(jobId, vmid, sourceFilename, false); + document.getElementById('btn-copy-start').onclick = () => copyVM(jobId, vmid, sourceFilename); async function poll() { try { @@ -356,52 +364,54 @@ async function reuseImage(jobId, vmid, sourceFilename, keep) { try { await fetch('/session/cleanup/' + jobId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ delete_staging_files: !keep }), + body: JSON.stringify({ delete_staging_files: true }), }); - if (keep) { - window.location.href = '/?vmid=' + vmid + '&source=' + encodeURIComponent(sourceFilename); - } else { - window.location.href = '/'; - } + window.location.href = '/'; } catch (err) { alert('Cleanup failed: ' + err.message); } } -async function submitReuse(e) { - e.preventDefault(); - const newVmid = document.getElementById('reuse-vmid').value; - const newVmName = document.getElementById('reuse-vmname').value.trim(); +async function cloneVM(jobId, sourceVmid) { + const newVmid = parseInt(document.getElementById('reuse-vmid').value) || 0; + const newName = document.getElementById('reuse-vmname').value.trim(); + if (!newVmid || !newName) { alert('Please fill VM Name and ID.'); return; } const status = document.getElementById('reuse-submit-status'); status.classList.remove('hidden'); - status.innerHTML = '
Submitting job...'; - - // Get original job data from the container - const container = document.getElementById('polling-container'); - const sourceFilename = container.dataset.sourceFilename; - const jobId = container.dataset.jobId; - - // Get the analysis data from the completed job's backend - let analysis; + status.innerHTML = ' Cloning VM...'; try { - const resp = await fetch('/session/status/' + jobId); - analysis = await resp.json(); + const resp = await fetch('/session/clone', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_vmid: sourceVmid, target_vmid: newVmid, target_name: newName }), + }); + const data = await resp.json(); + if (data.status === 'completed') { + status.innerHTML = '