From f3232f30acd1f2ae744d1d878ecd6a3e8ada44cd Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 13 Oct 2026 15:42:55 -0600 Subject: [PATCH] Debian installs show the Controller drive wizard wrolpi-bootstrap.sh created controller.yaml on installed systems, which hid the onboarding wizard. Installed systems now wait for the wizard, whose repair.sh finishes setup. "Run repair without external drive" now writes controller.yaml before starting repair, so the wizard does not return after repair. Co-Authored-By: Claude Opus 5.5 --- controller/api/onboarding.py | 20 +++++++++++ controller/lib/onboarding.py | 25 +++++++++++++ controller/templates/index.html | 2 +- controller/test/test_onboarding.py | 35 +++++++++++++++++++ controller/test/test_onboarding_api.py | 25 +++++++++++++ .../usr/local/sbin/wrolpi-bootstrap.sh | 13 +++++++ 6 files changed, 119 insertions(+), 1 deletion(-) 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