ISOs en Storage Pools + Upload Resumable — Implementation Plan
Synced read-only from
/home/synnet/mirrors/synvirt-product/docs/superpowers/plans/2026-05-30-isos-en-pools-y-upload-resumable.md. Edit at the source, not here.
ISOs en Storage Pools + Upload Resumable — Implementation Plan
Section titled “ISOs en Storage Pools + Upload Resumable — Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Los ISOs viven en un dataset ZFS <pool>/isos dentro de cada Storage Pool (creado al crear el pool), el dashboard pregunta el pool destino cuando hay >1, y la subida es resumable/network-friendly con barra de progreso real.
Architecture: Se introduce un dataset canónico <pool>/isos (mountpoint /<pool>/isos, hereda del root del pool) junto a los images/synvirt existentes. La constante fija ISO_ROOT se reemplaza por una resolución por-pool que consulta el PoolRegistry. El upload pasa de un único fetch con body ReadableStream (roto: sube "[object ReadableStream]", 23 B) a un protocolo por chunks con Content-Range y append a tempfile determinístico, reanudable desde el offset que el server reporta vía HEAD. El frontend usa XMLHttpRequest (xhr.upload.onprogress) por chunk con reintentos + backoff.
Tech Stack: Rust (axum, tokio, synvirt-zfs/synvirt-storage), TypeScript + Vue 3 (web-ux-v2, XMLHttpRequest), ZFS.
Decisiones arquitectónicas (cerradas):
- Dataset:
<pool>/isos, mountpoint/<pool>/isos(p.ej./storage/isos), heredado del root del pool. - Descubrimiento: el daemon resuelve los dirs de ISOs iterando el
PoolRegistry(un dir por pool); el escaneo de la library es la unión. - Upload:
POST /api/v1/isos?name=X&pool=YconContent-Range: bytes a-b/total; tempfile determinístico<isodir>/.upload-<sanitized>.part;HEAD /api/v1/isos?name=X&pool=YdevuelveUpload-Offset. Rename atómico al completar. - Con 1 pool el
pooles implícito (default del registry); con >1 el frontend obliga a elegir.
File Structure
Section titled “File Structure”Backend (Rust):
crates/daemon/src/api/install.rs— añadircreate_isos_dataset(pool)+ llamada junto a images/synvirt.crates/daemon/src/api/storage.rs— al crear pool (GUI), crear el dataset<pool>/isos.crates/daemon/src/storage_bootstrap.rs— boot-reconcile:ensure <pool>/isospara pools existentes (cubre el .70 ya instalado).crates/daemon/src/iso_paths.rs(NUEVO) — resolución por-pool:iso_dir_for_pool(label) -> PathBuf,iso_dirs(registry) -> Vec<(PoolLabel, PathBuf)>,sanitize_iso_name.crates/daemon/src/api/isos.rs—list/upload/deletepor pool; upload resumable conContent-Range;HEADpara offset.crates/daemon/src/iso_detection/api.rs— validariso_pathcontra los dirs por-pool, no la ruta fija.crates/daemon/src/api/metrics.rs—ISO_ROOTconst → resolución por-pool.
Frontend (TS/Vue):
crates/web-ux-v2/src/composables/useIsoUpload.ts— reescrito con XHR chunked + resumable + progreso.crates/web-ux-v2/src/components/iso/UploadModal.vue(o el que monte el modal) — selector de pool.crates/web-ux-v2/src/stores/isoLibrary.ts— pasar el pool elegido al upload; exponer pools disponibles.
FASE 1 — Backend: dataset <pool>/isos
Section titled “FASE 1 — Backend: dataset <pool>/isos”Task 1.1: Módulo de resolución de paths por pool
Section titled “Task 1.1: Módulo de resolución de paths por pool”Files:
-
Create:
crates/daemon/src/iso_paths.rs -
Modify:
crates/daemon/src/main.rs(declararmod iso_paths;) -
Test: en
iso_paths.rs(#[cfg(test)] mod tests) -
Step 1: Test que falla —
iso_dir_for_poolderiva<mountpoint>/isos:
#[test]fn iso_dir_is_isos_under_pool_mountpoint() { assert_eq!( iso_dir_for_mountpoint(std::path::Path::new("/storage")), std::path::PathBuf::from("/storage/isos") );}
#[test]fn sanitize_rejects_path_traversal_and_non_iso() { assert!(sanitize_iso_name("../etc/passwd").is_err()); assert!(sanitize_iso_name("a/b.iso").is_err()); assert!(sanitize_iso_name("ok name.iso").is_ok()); assert!(sanitize_iso_name("noext").is_err());}- Step 2:
cargo test -p synvirt-daemon iso_paths→ FAIL (función no existe). - Step 3: Implementar
iso_dir_for_mountpoint,sanitize_iso_name(mover la lógica actual deapi/isos.rs::sanitize_iso_nameaquí, DRY),iso_dirs(registry)que mapea cadaRegistryEntrya(label, mountpoint.join("isos")). - Step 4:
cargo test -p synvirt-daemon iso_paths→ PASS. - Step 5: Commit
feat(daemon): per-pool ISO dir resolution module.
Task 1.2: Crear <pool>/isos en install + GUI create + boot-reconcile
Section titled “Task 1.2: Crear <pool>/isos en install + GUI create + boot-reconcile”Files:
-
Modify:
crates/daemon/src/api/install.rs(helper + call, junto acreate_images_dataset/create_synvirt_state_dataset~líneas 767-769) -
Modify:
crates/daemon/src/api/storage.rs(create-pool handler) -
Modify:
crates/daemon/src/storage_bootstrap.rs(reconcile) -
Step 1: En
install.rsañadir:
fn create_isos_dataset(pool: &str) { // Mountpoint inherits from the pool root → /<pool>/isos. create_managed_dataset(pool, "isos", &format!("/{pool}/isos"), None);}y llamarlo en el bloque fresh-create y en el preserved-pool block donde se crean images/synvirt.
- Step 2: En
storage.rscreate-pool: tras crear el pool,backend.create_dataset(&DatasetSpec{ name: "<label>/isos", mountpoint: Some("/<label>/isos"), .. }). - Step 3: En
storage_bootstrap.rs: para cada pool del registry, si<pool>/isosno existe → crearlo (idempotente,zfs create -p), log ainfo!. Cubre el .70. - Step 4:
cargo build -p synvirt-daemon→ OK. Deploy daemon al .70 NO (daemon .70 = Fix54-dirty, más nuevo que repo). Verificación: nota manualzfs list | grep isos. - Step 5: Commit
feat(daemon): always provision <pool>/isos dataset on pool create + boot.
Task 1.3: list/delete de ISOs por pool
Section titled “Task 1.3: list/delete de ISOs por pool”Files:
-
Modify:
crates/daemon/src/api/isos.rs,crates/daemon/src/api/metrics.rs,crates/daemon/src/iso_detection/api.rs -
Step 1: Test:
listagrega ISOs de todos los pools, cadaIsoViewlleva supoollabel. -
Step 2:
cargo test→ FAIL. -
Step 3: Reemplazar
ISO_ROOTporiso_paths::iso_dirs(registry);listitera todos;deleteresuelve porpool.iso_detectionvalida contra el set de dirs. -
Step 4:
cargo test -p synvirt-daemon→ PASS. -
Step 5: Commit
feat(daemon): list/delete ISOs across per-pool datasets.
FASE 2 — Backend: upload resumable
Section titled “FASE 2 — Backend: upload resumable”Task 2.1: Upload por Content-Range + tempfile determinístico
Section titled “Task 2.1: Upload por Content-Range + tempfile determinístico”Files:
-
Modify:
crates/daemon/src/api/isos.rs(handlerupload+ nuevoHEAD) -
Test:
crates/daemon/src/api/isos.rstests -
Step 1: Tests:
- subir un solo
Content-Range: bytes 0-22/23deja el tempfile con 23 B; al completar (offset==total) → rename a final. - reanudar: tras escribir
0-9/23, unHEADdevuelveUpload-Offset: 10; un segundoPATCH/POSTconbytes 10-22/23completa. - chunk con
start != offset_actual→409 ConflictconUpload-Offsetcorrecto.
- subir un solo
-
Step 2:
cargo test→ FAIL. -
Step 3: Implementar:
- tempfile determinístico
<isodir>/.upload-<sanitized>.part(no random token, para reanudar). - parsear
Content-Range; abrir el.parten modo append/seek al offset; rechazar gap/overlap con 409 +Upload-Offset. - al alcanzar
total,fsync+ rename atómico a<isodir>/<name>. HEAD /api/v1/isos?name=X&pool=Y→Upload-Offset= tamaño actual del.part(0 si no existe),Upload-Lengthsi se conoce.- mantener el cap
MAX_UPLOAD_BYTESy la limpieza on-abort.
- tempfile determinístico
-
Step 4:
cargo test -p synvirt-daemon isos→ PASS. -
Step 5: Commit
feat(daemon): resumable ISO upload via Content-Range + HEAD offset.
FASE 3 — Frontend: upload robusto + selector de pool
Section titled “FASE 3 — Frontend: upload robusto + selector de pool”Task 3.1: useIsoUpload con XHR chunked + resumable + progreso
Section titled “Task 3.1: useIsoUpload con XHR chunked + resumable + progreso”Files:
-
Modify:
crates/web-ux-v2/src/composables/useIsoUpload.ts -
Test:
crates/web-ux-v2/src/composables/__tests__/useIsoUpload.spec.ts -
Step 1: Test (vitest + jsdom, XHR mock): subir un File de N bytes emite progreso creciente y resuelve con
{ name }; un chunk que falla se reintenta; un 409 conUpload-Offsetreposiciona y continúa. -
Step 2:
npm run test -- useIsoUpload→ FAIL. -
Step 3: Reescribir: por cada chunk de
CHUNK_BYTES(p.ej. 8 MiB) unXMLHttpRequestPOST conContent-Range,xhr.upload.onprogress→recordSample. Antes de empezar,HEADparaUpload-Offsety reanudar. Reintentos con backoff exponencial (cap N) ante error de red / 5xx;xhr.abort()para cancelar. Eliminar el pathfetch+ReadableStream(causa del bug 23 B). -
Step 4:
npm run test -- useIsoUpload+npm run typecheck+npm run lint -- --max-warnings 0→ PASS. -
Step 5: Commit
fix(web-ux-v2): resumable XHR ISO upload with real progress (fixes 23-byte upload).
Task 3.2: Selector de pool en el modal de subida
Section titled “Task 3.2: Selector de pool en el modal de subida”Files:
-
Modify:
crates/web-ux-v2/src/stores/isoLibrary.ts(exponer pools; pasar pool al upload) -
Modify: el componente del modal de upload (selector cuando
pools.length > 1) -
Step 1: Test: con 1 pool el upload usa ese pool sin UI; con >1 el botón “subir” queda deshabilitado hasta elegir pool.
-
Step 2:
npm run test→ FAIL. -
Step 3: Cargar pools (store de storage), dropdown obligatorio si
>1, pasarpoolauseIsoUpload/endpoint. -
Step 4: test + typecheck + lint → PASS.
-
Step 5: Commit
feat(web-ux-v2): pool picker on ISO upload when multiple pools.
FASE 4 — Limpieza + verificación E2E (.70)
Section titled “FASE 4 — Limpieza + verificación E2E (.70)”- Borrar los 2 ISOs basura de 23 B en
/var/lib/synvirt/isos/del .70. - Verificar en vivo en el .70 (tras desplegar el frontend; el daemon Fix54-dirty necesita rebuild/deploy coordinado con Tony por el estado dirty): subir un ISO real → barra de progreso, cortar la red a media subida → reanuda, archivo final con tamaño correcto y detección OK.
- Bump
iso-builder/VERSION(Fix N) — afecta artefacto distribuible (regla de version bump).
Notas de ejecución
Section titled “Notas de ejecución”- El daemon del .70 corre Fix54+build.803-dirty (más nuevo que el repo Fix53): no empujar binarios del repo sin coordinar con Tony; el frontend (estáticos) sí se puede desplegar.
- Mountpoints fijos (
images→/var/lib/libvirt/images,synvirt→/var/lib/synvirt) se mantienen; soloisosusa el esquema por-pool/<pool>/isos.