Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3929fae4 |
@@ -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`
|
||||
|
||||
@@ -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")),
|
||||
|
||||
+58
-1
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user