feat: session GUID isolation for multi-user support
Each browser gets a UUID stored in localStorage, set as a cookie,
and attached to every API call. Files go to session-specific dirs:
/mnt/converter/in/{guid}/ — uploads, downloads, SCP pulls
/mnt/converter/out/{guid}/ — converted QCOW2s
Changes:
- base.html: generates UUID via crypto.randomUUID(), stores in
localStorage + cookie, exposes as window.VM_BENCH_SID
- Frontend: all endpoints accept session_id, _staging() helper
creates session-aware paths on demand
- JavaScript: session_id appended to all FormData, set as
X-Session-ID header on raw uploads
- Backend models: JobSubmissionRequest.session_id field added
- Provisioner: _staging_in/_staging_out helpers, source path
resolution uses session-aware directory
- Converter: extract_if_needed skips re-extraction if dir exists
This commit is contained in:
parent
7ded831354
commit
63659e4f93
5 changed files with 54 additions and 8 deletions
|
|
@ -57,6 +57,7 @@ class JobSubmissionRequest(BaseModel):
|
||||||
target_storage: str = "local-lvm"
|
target_storage: str = "local-lvm"
|
||||||
additional_disks: List[DiskSpec] = []
|
additional_disks: List[DiskSpec] = []
|
||||||
target_disk_size_gb: Optional[int] = None
|
target_disk_size_gb: Optional[int] = None
|
||||||
|
session_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CleanupRequest(BaseModel):
|
class CleanupRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ logger = logging.getLogger("backend.provisioner")
|
||||||
|
|
||||||
STAGING_OUT = Path("/mnt/converter/out")
|
STAGING_OUT = Path("/mnt/converter/out")
|
||||||
|
|
||||||
|
def _staging_out(session_id: str = "") -> Path:
|
||||||
|
return STAGING_OUT / session_id if session_id else STAGING_OUT
|
||||||
|
|
||||||
# In-memory job tracking — survives as long as the process runs
|
# In-memory job tracking — survives as long as the process runs
|
||||||
_jobs: dict[str, dict] = {}
|
_jobs: dict[str, dict] = {}
|
||||||
_jobs_lock = threading.Lock()
|
_jobs_lock = threading.Lock()
|
||||||
|
|
@ -117,9 +120,9 @@ def clone_vm(req: CloneRequest) -> JobStatusResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_staging(vmid: int, delete: bool) -> dict:
|
def cleanup_staging(vmid: int, delete: bool, session_id: str = "") -> dict:
|
||||||
"""Remove staging files for a VM ID."""
|
"""Remove staging files for a VM ID."""
|
||||||
out_dir = STAGING_OUT / str(vmid)
|
out_dir = _staging_out(session_id) / str(vmid)
|
||||||
|
|
||||||
if not delete:
|
if not delete:
|
||||||
return {
|
return {
|
||||||
|
|
@ -220,7 +223,7 @@ def _process_job(job_id: str, req: JobSubmissionRequest):
|
||||||
_update_job(job_id, status=JobStatus.PROCESSING_CONVERSION, progress=10,
|
_update_job(job_id, status=JobStatus.PROCESSING_CONVERSION, progress=10,
|
||||||
message="Converting disk image...")
|
message="Converting disk image...")
|
||||||
|
|
||||||
out_dir = STAGING_OUT / str(vmid)
|
out_dir = _staging_out(req.session_id) / str(vmid)
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Collect all disks (boot disk + additional)
|
# Collect all disks (boot disk + additional)
|
||||||
|
|
@ -232,7 +235,8 @@ def _process_job(job_id: str, req: JobSubmissionRequest):
|
||||||
|
|
||||||
if spec.disk_type == DiskType.IMAGE_FILE:
|
if spec.disk_type == DiskType.IMAGE_FILE:
|
||||||
# Extract archive if needed, then locate the actual disk image
|
# Extract archive if needed, then locate the actual disk image
|
||||||
source = extract_if_needed(spec.source_filename)
|
src_path = _staging_in(req.session_id) / spec.source_filename
|
||||||
|
source = extract_if_needed(str(src_path))
|
||||||
disk = discover_disk(source)
|
disk = discover_disk(source)
|
||||||
source_path = disk.path
|
source_path = disk.path
|
||||||
logger.info("Disk %d: source=%s format=%s size=%.1f GiB",
|
logger.info("Disk %d: source=%s format=%s size=%.1f GiB",
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
||||||
_jinja = Environment(loader=FileSystemLoader(str(BASE / "templates")), autoescape=True)
|
_jinja = Environment(loader=FileSystemLoader(str(BASE / "templates")), autoescape=True)
|
||||||
STAGING = Path("/mnt/converter/in")
|
STAGING = Path("/mnt/converter/in")
|
||||||
|
|
||||||
|
def _staging(session_id: str = "") -> Path:
|
||||||
|
"""Session-aware staging directory."""
|
||||||
|
return STAGING / session_id if session_id else STAGING
|
||||||
|
|
||||||
|
def _get_session_id(request: Request) -> str:
|
||||||
|
"""Extract session ID from cookie or query param."""
|
||||||
|
sid = request.cookies.get("vm_bench_sid", "")
|
||||||
|
if not sid:
|
||||||
|
sid = request.query_params.get("session_id", "")
|
||||||
|
return sid
|
||||||
|
|
||||||
api = ApiClient()
|
api = ApiClient()
|
||||||
|
|
||||||
logger = logging.getLogger("vm-bench")
|
logger = logging.getLogger("vm-bench")
|
||||||
|
|
@ -141,9 +152,12 @@ def session_upload(
|
||||||
source_type: str = Form("upload"),
|
source_type: str = Form("upload"),
|
||||||
source_file: Optional[UploadFile] = File(None),
|
source_file: Optional[UploadFile] = File(None),
|
||||||
source_url: Optional[str] = Form(None),
|
source_url: Optional[str] = Form(None),
|
||||||
|
session_id: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Phase 1 — acquire the source file. Returns JSON so the frontend can
|
"""Phase 1 — acquire the source file. Returns JSON so the frontend can
|
||||||
show progress, then call /session/analyze separately."""
|
show progress, then call /session/analyze separately."""
|
||||||
|
staging = _staging(session_id)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
err = _validate_vmid(vmid)
|
err = _validate_vmid(vmid)
|
||||||
if err:
|
if err:
|
||||||
return JSONResponse({"phase": "error", "error": err}, status_code=400)
|
return JSONResponse({"phase": "error", "error": err}, status_code=400)
|
||||||
|
|
@ -156,7 +170,7 @@ def session_upload(
|
||||||
return JSONResponse({"phase": "error", "error": "No file uploaded."}, status_code=400)
|
return JSONResponse({"phase": "error", "error": "No file uploaded."}, status_code=400)
|
||||||
|
|
||||||
filename = source_file.filename
|
filename = source_file.filename
|
||||||
dest = STAGING / filename
|
dest = staging / filename
|
||||||
|
|
||||||
content_length = request.headers.get("content-length")
|
content_length = request.headers.get("content-length")
|
||||||
if content_length:
|
if content_length:
|
||||||
|
|
@ -203,11 +217,11 @@ def session_upload(
|
||||||
return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400)
|
return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400)
|
||||||
|
|
||||||
filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}"
|
filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}"
|
||||||
dest = STAGING / filename
|
dest = staging / filename
|
||||||
|
|
||||||
_active_downloads.pop(filename, None)
|
_active_downloads.pop(filename, None)
|
||||||
|
|
||||||
usage = shutil.disk_usage(STAGING)
|
usage = shutil.disk_usage(staging)
|
||||||
free_gb = usage.free / (1024**3)
|
free_gb = usage.free / (1024**3)
|
||||||
if free_gb < 50:
|
if free_gb < 50:
|
||||||
logger.warning("Low disk: %.1f GB free — download may fail", free_gb)
|
logger.warning("Low disk: %.1f GB free — download may fail", free_gb)
|
||||||
|
|
@ -254,6 +268,7 @@ async def upload_raw(request: Request):
|
||||||
filename = request.headers.get("X-Filename", "upload.bin")
|
filename = request.headers.get("X-Filename", "upload.bin")
|
||||||
vmid_str = request.headers.get("X-VMID", "")
|
vmid_str = request.headers.get("X-VMID", "")
|
||||||
vm_name = request.headers.get("X-VM-Name", "")
|
vm_name = request.headers.get("X-VM-Name", "")
|
||||||
|
session_id = request.headers.get("X-Session-ID", "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
vmid = int(vmid_str)
|
vmid = int(vmid_str)
|
||||||
|
|
@ -264,7 +279,9 @@ async def upload_raw(request: Request):
|
||||||
if err:
|
if err:
|
||||||
return JSONResponse({"phase": "error", "error": err}, status_code=400)
|
return JSONResponse({"phase": "error", "error": err}, status_code=400)
|
||||||
|
|
||||||
dest = STAGING / filename
|
staging = _staging(session_id)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = staging / filename
|
||||||
content_length = request.headers.get("content-length")
|
content_length = request.headers.get("content-length")
|
||||||
if content_length:
|
if content_length:
|
||||||
estimated_gb = int(content_length) / (1024**3)
|
estimated_gb = int(content_length) / (1024**3)
|
||||||
|
|
@ -445,6 +462,7 @@ async def confirm_session(
|
||||||
target_disk_size_gb: Optional[int] = Form(None),
|
target_disk_size_gb: Optional[int] = Form(None),
|
||||||
auto_detect_boot: str = Form("true"),
|
auto_detect_boot: str = Form("true"),
|
||||||
boot_type: str = Form("uefi"),
|
boot_type: str = Form("uefi"),
|
||||||
|
session_id: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Build the job payload and submit to the backend."""
|
"""Build the job payload and submit to the backend."""
|
||||||
# Validate VM ID range
|
# Validate VM ID range
|
||||||
|
|
@ -460,6 +478,7 @@ async def confirm_session(
|
||||||
"target_storage": target_storage,
|
"target_storage": target_storage,
|
||||||
"auto_detect_boot": auto_detect_boot == "true",
|
"auto_detect_boot": auto_detect_boot == "true",
|
||||||
"boot_type": boot_type,
|
"boot_type": boot_type,
|
||||||
|
"session_id": session_id,
|
||||||
"boot_disk": {
|
"boot_disk": {
|
||||||
"disk_type": "image_file",
|
"disk_type": "image_file",
|
||||||
"source_filename": source_filename,
|
"source_filename": source_filename,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,22 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>VM Bench — Proxmox Image Converter</title>
|
<title>VM Bench — Proxmox Image Converter</title>
|
||||||
<link rel="stylesheet" href="/static/proxmox.css?v=5">
|
<link rel="stylesheet" href="/static/proxmox.css?v=5">
|
||||||
|
<script>
|
||||||
|
// Session GUID — one per browser, persists across tabs/refreshes
|
||||||
|
(function() {
|
||||||
|
var sid = localStorage.getItem('vm-bench-sid');
|
||||||
|
if (!sid) {
|
||||||
|
sid = crypto.randomUUID ? crypto.randomUUID() :
|
||||||
|
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
var r = Math.random()*16|0, v = c=='x'?r:(r&0x3|0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
localStorage.setItem('vm-bench-sid', sid);
|
||||||
|
}
|
||||||
|
document.cookie = 'vm_bench_sid=' + sid + ';path=/;SameSite=Lax';
|
||||||
|
window.VM_BENCH_SID = sid;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,12 @@ async function startSession(e) {
|
||||||
const vmid = document.getElementById('vmid').value;
|
const vmid = document.getElementById('vmid').value;
|
||||||
const vmName = document.getElementById('vm-name').value.trim();
|
const vmName = document.getElementById('vm-name').value.trim();
|
||||||
const sourceType = document.getElementById('source-type').value;
|
const sourceType = document.getElementById('source-type').value;
|
||||||
|
const sessionId = window.VM_BENCH_SID || '';
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('vmid', vmid);
|
formData.append('vmid', vmid);
|
||||||
formData.append('vm_name', vmName);
|
formData.append('vm_name', vmName);
|
||||||
formData.append('source_type', sourceType);
|
formData.append('source_type', sourceType);
|
||||||
|
formData.append('session_id', sessionId);
|
||||||
|
|
||||||
if (sourceType === 'upload') {
|
if (sourceType === 'upload') {
|
||||||
const fileInput = document.getElementById('source-file');
|
const fileInput = document.getElementById('source-file');
|
||||||
|
|
@ -151,6 +153,8 @@ async function handleUpload(formData) {
|
||||||
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
|
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
|
||||||
xhr.setRequestHeader('X-VMID', vmid);
|
xhr.setRequestHeader('X-VMID', vmid);
|
||||||
xhr.setRequestHeader('X-VM-Name', vmName);
|
xhr.setRequestHeader('X-VM-Name', vmName);
|
||||||
|
xhr.setRequestHeader('X-Session-ID', sessionId);
|
||||||
|
xhr.setRequestHeader('X-Session-ID', sessionId);
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
|
|
@ -233,6 +237,7 @@ async function runAnalysis(vmid, filename, vmName) {
|
||||||
setPhase('Step 2/2: Analysing source image...', 0, true);
|
setPhase('Step 2/2: Analysing source image...', 0, true);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName);
|
fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName);
|
||||||
|
fd.append('session_id', window.VM_BENCH_SID || '');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
|
const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
|
|
@ -268,6 +273,7 @@ async function submitJob(e) {
|
||||||
status.innerHTML = '<div class="spinner"></div> Submitting job...';
|
status.innerHTML = '<div class="spinner"></div> Submitting job...';
|
||||||
|
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
formData.append('session_id', window.VM_BENCH_SID || '');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
|
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue