Add API update controls #22
This commit is contained in:
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user