diff --git a/controller/api/onboarding.py b/controller/api/onboarding.py index 1e28bf0a..64835a58 100644 --- a/controller/api/onboarding.py +++ b/controller/api/onboarding.py @@ -19,6 +19,7 @@ from controller.lib.onboarding import ( cancel_probe, commit_onboarding, get_onboarding_candidates, + onboard_without_drive, probe_drive, ) @@ -106,6 +107,25 @@ async def commit(request: OnboardingCommitRequest): ) +@router.post("/without-drive", response_model=OnboardingCommitResponse) +async def without_drive(): + """Keep the library on the internal disk, and start repair.""" + _check_preconditions() + + try: + result = onboard_without_drive() + except Exception as e: + result = {"success": False, "error": str(e)} + 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).""" diff --git a/controller/lib/onboarding.py b/controller/lib/onboarding.py index 385bd771..aceafc9c 100644 --- a/controller/lib/onboarding.py +++ b/controller/lib/onboarding.py @@ -165,6 +165,31 @@ def cancel_probe() -> dict: return {"success": True} +def onboard_without_drive() -> dict: + """ + Set up WROLPi with the library on the internal disk, then start repair. + + controller.yaml marks the primary drive as set up; without it the wizard + would be shown again after repair. + + Returns: + dict with success, mounts, and repair_started + """ + config_dir = get_media_directory() / "config" + config_dir.mkdir(parents=True, exist_ok=True) + save_config() + + repair_result = start_script("repair") + if not repair_result.get("success"): + logger.warning("Failed to start repair: %s", repair_result.get("error")) + + return { + "success": True, + "mounts": [], + "repair_started": bool(repair_result.get("success")), + } + + def commit_onboarding( device_path: str, fstype: str, diff --git a/controller/templates/index.html b/controller/templates/index.html index 2e2e9c27..5e336587 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -2547,7 +2547,7 @@ document.querySelector('#onboarding-progress p').textContent = 'Starting repair without external drive. Please wait...'; try { - const resp = await fetch('api/scripts/repair/start', { + const resp = await fetch('api/onboarding/without-drive', { method: 'POST', headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, signal: AbortSignal.timeout(60000) diff --git a/controller/test/test_onboarding.py b/controller/test/test_onboarding.py index 6d8b7cbb..e5c82fd0 100644 --- a/controller/test/test_onboarding.py +++ b/controller/test/test_onboarding.py @@ -14,6 +14,7 @@ from controller.lib.onboarding import ( cancel_probe, commit_onboarding, get_onboarding_candidates, + onboard_without_drive, probe_drive, ) @@ -516,3 +517,37 @@ class TestCommitOnboardingShadowedData: result = commit_onboarding("/dev/sda1", "ext4", force_shadowed=True) assert result["success"] is True + + +class TestOnboardWithoutDrive: + """Tests for onboard_without_drive.""" + + def test_writes_marker_and_starts_repair(self, mock_docker_mode, tmp_path): + """The library stays on the internal disk, and the wizard does not return after repair.""" + media_dir = tmp_path / "wrolpi" + media_dir.mkdir() + config_path = media_dir / "config" / "controller.yaml" + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", config_path), \ + mock.patch("controller.lib.onboarding.start_script", return_value={"success": True}) as start: + result = onboard_without_drive() + + assert result == {"success": True, "mounts": [], "repair_started": True} + assert config_path.is_file() + start.assert_called_once_with("repair") + + def test_repair_failure_is_reported(self, mock_docker_mode, tmp_path): + media_dir = tmp_path / "wrolpi" + media_dir.mkdir() + config_path = media_dir / "config" / "controller.yaml" + + with mock.patch("controller.lib.onboarding.get_media_directory", return_value=media_dir), \ + mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", config_path), \ + mock.patch("controller.lib.onboarding.start_script", + return_value={"success": False, "error": "already running"}): + result = onboard_without_drive() + + assert result["success"] is True + assert result["repair_started"] is False + assert config_path.is_file() diff --git a/controller/test/test_onboarding_api.py b/controller/test/test_onboarding_api.py index 97ecc824..ed269aa7 100644 --- a/controller/test/test_onboarding_api.py +++ b/controller/test/test_onboarding_api.py @@ -15,6 +15,7 @@ class TestOnboardingDockerAndMounted: ("post", "/api/onboarding/probe", {"device_path": "/dev/sda1", "fstype": "ext4"}), ("post", "/api/onboarding/commit", {"device_path": "/dev/sda1", "fstype": "ext4"}), ("post", "/api/onboarding/cancel", None), + ("post", "/api/onboarding/without-drive", None), ]) def test_endpoint_returns_501_in_docker(self, test_client_docker_mode, method, endpoint, payload): client_call = getattr(test_client_docker_mode, method) @@ -24,6 +25,7 @@ class TestOnboardingDockerAndMounted: @pytest.mark.parametrize("method,endpoint,payload", [ ("get", "/api/onboarding/candidates", None), ("post", "/api/onboarding/probe", {"device_path": "/dev/sda1", "fstype": "ext4"}), + ("post", "/api/onboarding/without-drive", None), ]) def test_endpoint_returns_409_when_drive_mounted(self, test_client, method, endpoint, payload): """Onboarding endpoints reject when the primary drive is already mounted.""" @@ -196,3 +198,26 @@ class TestOnboardingCancelEndpoint: ): response = test_client.post("/api/onboarding/cancel") assert response.status_code == 200 + + +class TestOnboardingWithoutDrive: + + def test_without_drive_success(self, test_client): + with mock.patch("controller.api.onboarding.is_primary_drive_mounted", return_value=False), \ + mock.patch("controller.api.onboarding.onboard_without_drive", + return_value={"success": True, "mounts": [], "repair_started": True}) as onboard: + response = test_client.post("/api/onboarding/without-drive") + + assert response.status_code == 200 + assert response.json()["success"] is True + assert response.json()["repair_started"] is True + onboard.assert_called_once_with() + + def test_without_drive_failure(self, test_client): + with mock.patch("controller.api.onboarding.is_primary_drive_mounted", return_value=False), \ + mock.patch("controller.api.onboarding.onboard_without_drive", + return_value={"success": False, "error": "disk full"}): + response = test_client.post("/api/onboarding/without-drive") + + assert response.status_code == 500 + assert "disk full" in response.json()["detail"] diff --git a/debian-live-config/config/includes.chroot/usr/local/sbin/wrolpi-bootstrap.sh b/debian-live-config/config/includes.chroot/usr/local/sbin/wrolpi-bootstrap.sh index 8a005c64..98ba6de7 100644 --- a/debian-live-config/config/includes.chroot/usr/local/sbin/wrolpi-bootstrap.sh +++ b/debian-live-config/config/includes.chroot/usr/local/sbin/wrolpi-bootstrap.sh @@ -27,6 +27,8 @@ # drive, or is a plain directory on the root filesystem if the user # installed without one. The SQLite database lives inside # /media/wrolpi/config/ in every shape, so it travels with the drive. +# Until the Controller onboarding wizard writes controller.yaml, this +# script does nothing; the wizard's repair.sh finishes setup. set -euo pipefail @@ -225,6 +227,17 @@ if [ ! -d /media/wrolpi ]; then fi chown wrolpi:wrolpi /media/wrolpi 2>/dev/null || true +# --- Installed: wait for the Controller onboarding wizard --------------- +# Until the user picks a drive (or the internal disk) in the wizard, there +# is no controller.yaml. Leave Caddy on the onboarding Caddyfile from +# wrolpi-first-startup.service so every page leads to the wizard, and leave +# certificates, the database, and services to the wizard's repair.sh. + +if [ "$MODE" = "installed" ] && [ ! -e /media/wrolpi/config/controller.yaml ]; then + log "primary drive is not set up; the Controller onboarding wizard will finish setup" + exit 0 +fi + mkdir -p /media/wrolpi/config chown wrolpi:wrolpi /media/wrolpi/config