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.
+
+
+
+
No unmounted drives detected. Plug in a USB drive and refresh.
+
+
+
+
+
Confirm Drive Setup
+
+
Found WROLPi configuration on this drive.
+
+
+
+
+
+
+
No WROLPi configuration found on this drive.
+
This may be a new drive or the wrong drive. You can still use it as a fresh WROLPi drive.
+
+
+
+
+
+
+
+
+
Setting Up Drives...
+
+
Mounting drives and starting repair. Please wait...
+
+
+
+
+
Setup Complete!
+
Repair is running. The main UI will be available shortly.
+
You will be redirected automatically, or you can continue using this page.
+
+
+
+
+
Setup Failed
+
+
+
+
+{% 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