From 8a3929fae45ae3a96730da76d885ae983f5dd285 Mon Sep 17 00:00:00 2001 From: Daniel Gradman-Svendsen Date: Wed, 29 Jul 2026 20:36:22 +0200 Subject: [PATCH] Add release-aware self update #25 --- README.md | 3 +- importarr/config.py | 4 +++ importarr/main.py | 59 +++++++++++++++++++++++++++++++++- tests/test_queue_controls.py | 61 ++++++++++++++++++++++++++++++++++-- 4 files changed, 122 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2788cd9..7fc648c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ sudo -n sh /opt/importarr/repo-upgrade.sh The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`. -Installed deployments can expose the same operation through the authenticated API. Configure `IMPORTARR_UPDATE_COMMAND` when the default `sh deploy/repo-upgrade.sh` is not correct for the service working directory, and configure `IMPORTARR_RESTART_COMMAND` when the default `systemctl restart importarr.service` needs a wrapper such as sudo. +Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the running `IMPORTARR_VERSION`. `POST /api/control/update` performs the same check and only runs the update command when a newer release tag exists. Configure `IMPORTARR_UPDATE_COMMAND` when the default `sh deploy/repo-upgrade.sh` is not correct for the service working directory, and configure `IMPORTARR_RESTART_COMMAND` when the default `systemctl restart importarr.service` needs a wrapper such as sudo. Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then installed from the tagged checkout or artifact. @@ -78,6 +78,7 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2. - `POST /api/control/stop` - `POST /api/control/cancel-current` - `POST /api/control/restart` +- `GET /api/control/update-check` - `POST /api/control/update` - `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }` - `POST /api/import/run-now` diff --git a/importarr/config.py b/importarr/config.py index a146964..569f25f 100644 --- a/importarr/config.py +++ b/importarr/config.py @@ -24,6 +24,8 @@ class Settings(BaseModel): auth_token: str | None = None restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "importarr.service"]) update_command: list[str] = Field(default_factory=lambda: ["sh", "deploy/repo-upgrade.sh"]) + update_release_url: str = "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest" + update_check_timeout_seconds: int = Field(default=15, ge=1) control_command_timeout_seconds: int = Field(default=120, ge=1) bind_host: str = "127.0.0.1" bind_port: int = 8765 @@ -48,6 +50,8 @@ class Settings(BaseModel): auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"), restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "importarr.service"]), update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["sh", "deploy/repo-upgrade.sh"]), + update_release_url=os.getenv("IMPORTARR_UPDATE_RELEASE_URL", "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest"), + update_check_timeout_seconds=int(os.getenv("IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS", "15")), control_command_timeout_seconds=int(os.getenv("IMPORTARR_CONTROL_COMMAND_TIMEOUT_SECONDS", "120")), bind_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"), bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")), diff --git a/importarr/main.py b/importarr/main.py index 23fecec..02880da 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -51,6 +51,14 @@ class ControlCommandResponse(BaseModel): stderr: str +class UpdateCheckResponse(BaseModel): + status: str + current_version: str + latest_version: str | None + update_available: bool + release_url: str | None = None + + class AppSettingsUpdate(BaseModel): sab_url: str sab_api_key: str | None = None @@ -252,7 +260,56 @@ def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]: @app.post("/api/control/update") def update_service(_: None = Depends(require_write_auth)) -> dict[str, object]: - return _run_control_command(settings.update_command) + update = check_update_available() + if not update["update_available"]: + return {**update, "command": settings.update_command, "stdout": "", "stderr": ""} + result = _run_control_command(settings.update_command) + return {**update, "command_result": result} + + +@app.get("/api/control/update-check") +def update_check(_: None = Depends(require_write_auth)) -> dict[str, object]: + return check_update_available() + + +def check_update_available() -> dict[str, object]: + current = build_info()["version"] + try: + with httpx.Client(timeout=settings.update_check_timeout_seconds) as client: + response = client.get(settings.update_release_url, headers={"Accept": "application/json"}) + response.raise_for_status() + release = response.json() + except Exception as exc: + raise HTTPException(status_code=502, detail=f"release check failed: {exc.__class__.__name__}") from exc + + latest = str(release.get("tag_name") or release.get("name") or "").strip() + if not latest: + raise HTTPException(status_code=502, detail="release check failed: latest release has no tag_name") + + payload = UpdateCheckResponse( + status="update_available" if _is_newer_version(latest, current) else "current", + current_version=current, + latest_version=latest, + update_available=_is_newer_version(latest, current), + release_url=release.get("html_url"), + ) + return payload.model_dump() + + +def _is_newer_version(candidate: str, current: str) -> bool: + candidate_version = _version_key(candidate) + current_version = _version_key(current) + if candidate_version is None or current_version is None: + return candidate.lstrip("vV") != current.lstrip("vV") and current in {"", "development"} + return candidate_version > current_version + + +def _version_key(value: str) -> tuple[int, ...] | None: + normalized = value.strip().lstrip("vV").split("-", 1)[0] + parts = normalized.split(".") + if not parts or any(not part.isdigit() for part in parts): + return None + return tuple(int(part) for part in parts) def _run_control_command(command: list[str]) -> dict[str, object]: diff --git a/tests/test_queue_controls.py b/tests/test_queue_controls.py index 43e4064..313c785 100644 --- a/tests/test_queue_controls.py +++ b/tests/test_queue_controls.py @@ -136,6 +136,7 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch): def test_control_update_runs_configured_command(tmp_path, monkeypatch): main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) main.settings.update_command = ["upgrade", "now"] + monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "0.1.0", "latest_version": "0.2.0", "update_available": True, "release_url": None}) calls = [] @@ -147,13 +148,67 @@ def test_control_update_runs_configured_command(tmp_path, monkeypatch): result = main.update_service() - assert result["status"] == "ok" - assert result["command"] == ["upgrade", "now"] - assert result["stdout"] == "updated" + assert result["status"] == "update_available" + assert result["command_result"]["status"] == "ok" + assert result["command_result"]["command"] == ["upgrade", "now"] + assert result["command_result"]["stdout"] == "updated" assert calls[0][0] == ["upgrade", "now"] assert calls[0][1].get("shell") is not True +def test_control_update_skips_command_when_current(tmp_path, monkeypatch): + main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) + main.settings.update_command = ["upgrade", "now"] + monkeypatch.setattr(main, "check_update_available", lambda: {"status": "current", "current_version": "0.2.0", "latest_version": "v0.2.0", "update_available": False, "release_url": None}) + + def fake_run(command, **kwargs): + raise AssertionError("update command should not run without a newer release") + + monkeypatch.setattr(main.subprocess, "run", fake_run) + + result = main.update_service() + + assert result["status"] == "current" + assert result["command"] == ["upgrade", "now"] + assert result["update_available"] is False + + +def test_update_check_compares_latest_release(tmp_path, monkeypatch): + main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) + monkeypatch.setenv("IMPORTARR_VERSION", "0.1.0") + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"tag_name": "v0.2.0", "html_url": "https://example.test/releases/v0.2.0"} + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url, headers): + assert url == main.settings.update_release_url + assert headers["Accept"] == "application/json" + return FakeResponse() + + monkeypatch.setattr(main.httpx, "Client", FakeClient) + + result = main.check_update_available() + + assert result["status"] == "update_available" + assert result["current_version"] == "0.1.0" + assert result["latest_version"] == "v0.2.0" + assert result["update_available"] is True + + def test_control_restart_reports_command_failure(tmp_path, monkeypatch): main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) main.settings.restart_command = ["restart"]