From 5752e9fb2fcd891fdb6bba32a324dcbc91a7e4a2 Mon Sep 17 00:00:00 2001 From: Daniel Gradman-Svendsen Date: Wed, 29 Jul 2026 20:11:26 +0200 Subject: [PATCH] Add API update controls #22 --- README.md | 4 ++++ importarr/config.py | 14 +++++++++++ importarr/main.py | 46 ++++++++++++++++++++++++++++++++++++ tests/test_queue_controls.py | 40 +++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/README.md b/README.md index beaf567..2788cd9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ 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. + 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. ### Required setup @@ -75,6 +77,8 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2. - `POST /api/control/pause` - `POST /api/control/stop` - `POST /api/control/cancel-current` +- `POST /api/control/restart` +- `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 dd21bb8..a146964 100644 --- a/importarr/config.py +++ b/importarr/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shlex from pathlib import Path from pydantic import BaseModel, Field @@ -21,6 +22,9 @@ class Settings(BaseModel): sonarr_url: str | None = None sonarr_api_key: str | None = None 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"]) + control_command_timeout_seconds: int = Field(default=120, ge=1) bind_host: str = "127.0.0.1" bind_port: int = 8765 poll_seconds: int = Field(default=60, ge=5) @@ -42,6 +46,9 @@ class Settings(BaseModel): sonarr_url=os.getenv("IMPORTARR_SONARR_URL"), sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"), 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"]), + 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")), poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")), @@ -63,3 +70,10 @@ def _env_secret(name: str) -> str | None: if file_value: return Path(file_value).read_text(encoding="utf-8").strip() return os.getenv(name) + + +def _env_command(name: str, default: list[str]) -> list[str]: + value = os.getenv(name) + if not value: + return default + return shlex.split(value) diff --git a/importarr/main.py b/importarr/main.py index 3c9cef7..23fecec 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import subprocess from typing import Annotated import uvicorn @@ -42,6 +43,14 @@ class QueueItemActionRequest(BaseModel): action: str +class ControlCommandResponse(BaseModel): + status: str + command: list[str] + returncode: int + stdout: str + stderr: str + + class AppSettingsUpdate(BaseModel): sab_url: str sab_api_key: str | None = None @@ -236,6 +245,43 @@ def cancel_current(_: None = Depends(require_write_auth)) -> dict[str, object]: return control_status() +@app.post("/api/control/restart") +def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]: + return _run_control_command(settings.restart_command) + + +@app.post("/api/control/update") +def update_service(_: None = Depends(require_write_auth)) -> dict[str, object]: + return _run_control_command(settings.update_command) + + +def _run_control_command(command: list[str]) -> dict[str, object]: + if not command: + raise HTTPException(status_code=500, detail="control command is not configured") + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=settings.control_command_timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException(status_code=504, detail=f"control command timed out after {exc.timeout} seconds") from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"control command failed to start: {exc.__class__.__name__}") from exc + payload = ControlCommandResponse( + status="ok" if result.returncode == 0 else "failed", + command=command, + returncode=result.returncode, + stdout=result.stdout[-4000:], + stderr=result.stderr[-4000:], + ).model_dump() + if result.returncode != 0: + raise HTTPException(status_code=500, detail=payload) + return payload + + @app.get("/api/manual-batches") def manual_batches() -> list[dict[str, object]]: if queue_accepting_new_jobs(): diff --git a/tests/test_queue_controls.py b/tests/test_queue_controls.py index c770ee3..43e4064 100644 --- a/tests/test_queue_controls.py +++ b/tests/test_queue_controls.py @@ -131,3 +131,43 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch): assert source.exists() assert not any(movies.glob("*.partial")) assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped" + + +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"] + + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return main.subprocess.CompletedProcess(command, 0, stdout="updated", stderr="") + + monkeypatch.setattr(main.subprocess, "run", fake_run) + + result = main.update_service() + + assert result["status"] == "ok" + assert result["command"] == ["upgrade", "now"] + assert result["stdout"] == "updated" + assert calls[0][0] == ["upgrade", "now"] + assert calls[0][1].get("shell") is not 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"] + + def fake_run(command, **kwargs): + return main.subprocess.CompletedProcess(command, 1, stdout="", stderr="failed") + + monkeypatch.setattr(main.subprocess, "run", fake_run) + + try: + main.restart_service() + except main.HTTPException as exc: + assert exc.status_code == 500 + assert exc.detail["status"] == "failed" + assert exc.detail["stderr"] == "failed" + else: + raise AssertionError("expected HTTPException")