Skip to content

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=Y con Content-Range: bytes a-b/total; tempfile determinístico <isodir>/.upload-<sanitized>.part; HEAD /api/v1/isos?name=X&pool=Y devuelve Upload-Offset. Rename atómico al completar.
  • Con 1 pool el pool es implícito (default del registry); con >1 el frontend obliga a elegir.

Backend (Rust):

  • crates/daemon/src/api/install.rs — añadir create_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>/isos para 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.rslist/upload/delete por pool; upload resumable con Content-Range; HEAD para offset.
  • crates/daemon/src/iso_detection/api.rs — validar iso_path contra los dirs por-pool, no la ruta fija.
  • crates/daemon/src/api/metrics.rsISO_ROOT const → 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.

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 (declarar mod iso_paths;)

  • Test: en iso_paths.rs (#[cfg(test)] mod tests)

  • Step 1: Test que fallaiso_dir_for_pool deriva <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 de api/isos.rs::sanitize_iso_name aquí, DRY), iso_dirs(registry) que mapea cada RegistryEntry a (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 a create_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.rs añ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.rs create-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>/isos no existe → crearlo (idempotente, zfs create -p), log a info!. 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 manual zfs list | grep isos.
  • Step 5: Commit feat(daemon): always provision <pool>/isos dataset on pool create + boot.

Files:

  • Modify: crates/daemon/src/api/isos.rs, crates/daemon/src/api/metrics.rs, crates/daemon/src/iso_detection/api.rs

  • Step 1: Test: list agrega ISOs de todos los pools, cada IsoView lleva su pool label.

  • Step 2: cargo test → FAIL.

  • Step 3: Reemplazar ISO_ROOT por iso_paths::iso_dirs(registry); list itera todos; delete resuelve por pool. iso_detection valida 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.


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 (handler upload + nuevo HEAD)

  • Test: crates/daemon/src/api/isos.rs tests

  • Step 1: Tests:

    • subir un solo Content-Range: bytes 0-22/23 deja el tempfile con 23 B; al completar (offset==total) → rename a final.
    • reanudar: tras escribir 0-9/23, un HEAD devuelve Upload-Offset: 10; un segundo PATCH/POST con bytes 10-22/23 completa.
    • chunk con start != offset_actual409 Conflict con Upload-Offset correcto.
  • 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 .part en 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=YUpload-Offset = tamaño actual del .part (0 si no existe), Upload-Length si se conoce.
    • mantener el cap MAX_UPLOAD_BYTES y la limpieza on-abort.
  • 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 con Upload-Offset reposiciona y continúa.

  • Step 2: npm run test -- useIsoUpload → FAIL.

  • Step 3: Reescribir: por cada chunk de CHUNK_BYTES (p.ej. 8 MiB) un XMLHttpRequest POST con Content-Range, xhr.upload.onprogressrecordSample. Antes de empezar, HEAD para Upload-Offset y reanudar. Reintentos con backoff exponencial (cap N) ante error de red / 5xx; xhr.abort() para cancelar. Eliminar el path fetch+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, pasar pool a useIsoUpload/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).

  • 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; solo isos usa el esquema por-pool /<pool>/isos.