Add API update controls #22

This commit is contained in:
2026-07-29 20:11:26 +02:00
parent 6cdf1f5d49
commit 5752e9fb2f
4 changed files with 104 additions and 0 deletions
+4
View File
@@ -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`. 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. 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 ### 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/pause`
- `POST /api/control/stop` - `POST /api/control/stop`
- `POST /api/control/cancel-current` - `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/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
- `POST /api/import/run-now` - `POST /api/import/run-now`
+14
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -21,6 +22,9 @@ class Settings(BaseModel):
sonarr_url: str | None = None sonarr_url: str | None = None
sonarr_api_key: str | None = None sonarr_api_key: str | None = None
auth_token: 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_host: str = "127.0.0.1"
bind_port: int = 8765 bind_port: int = 8765
poll_seconds: int = Field(default=60, ge=5) poll_seconds: int = Field(default=60, ge=5)
@@ -42,6 +46,9 @@ class Settings(BaseModel):
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"), sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"), sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"), 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_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"),
bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")), bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")),
poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")), poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")),
@@ -63,3 +70,10 @@ def _env_secret(name: str) -> str | None:
if file_value: if file_value:
return Path(file_value).read_text(encoding="utf-8").strip() return Path(file_value).read_text(encoding="utf-8").strip()
return os.getenv(name) 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)
+46
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import subprocess
from typing import Annotated from typing import Annotated
import uvicorn import uvicorn
@@ -42,6 +43,14 @@ class QueueItemActionRequest(BaseModel):
action: str action: str
class ControlCommandResponse(BaseModel):
status: str
command: list[str]
returncode: int
stdout: str
stderr: str
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):
sab_url: str sab_url: str
sab_api_key: str | None = None sab_api_key: str | None = None
@@ -236,6 +245,43 @@ def cancel_current(_: None = Depends(require_write_auth)) -> dict[str, object]:
return control_status() 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") @app.get("/api/manual-batches")
def manual_batches() -> list[dict[str, object]]: def manual_batches() -> list[dict[str, object]]:
if queue_accepting_new_jobs(): if queue_accepting_new_jobs():
+40
View File
@@ -131,3 +131,43 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch):
assert source.exists() assert source.exists()
assert not any(movies.glob("*.partial")) assert not any(movies.glob("*.partial"))
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped" 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")