Debian installs show the Controller drive wizard (#826)

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 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-13 15:50:20 -06:00 • committed by GitHub
parent 697a8180c7
commit dc2ab22ae0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 119 additions and 1 deletions

View file

@ -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)."""

View file

@ -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,

View file

@ -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)

View file

@ -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()

View file

@ -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"]

View file

@ -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