Harden live release workflow #25

This commit is contained in:
2026-07-31 09:28:45 +02:00
parent 939dc9819d
commit e8b0645ea6
19 changed files with 223 additions and 43 deletions
+6
View File
@@ -15,3 +15,9 @@ def test_file_secret_env_vars_are_supported(tmp_path, monkeypatch):
assert settings.sab_api_key == "sab-secret"
assert settings.auth_token == "auth-secret"
def test_default_update_command_uses_installed_absolute_path(monkeypatch):
monkeypatch.delenv("IMPORTARR_UPDATE_COMMAND", raising=False)
assert Settings.from_env().update_command == ["/bin/sh", "/opt/importarr/repo-upgrade.sh"]
+32
View File
@@ -0,0 +1,32 @@
import fcntl
import os
import subprocess
from pathlib import Path
def test_repo_upgrade_refuses_concurrent_run(tmp_path):
script = Path(__file__).parents[1] / "deploy" / "repo-upgrade.sh"
lock_path = tmp_path / "repo-upgrade.lock"
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake_id = bin_dir / "id"
fake_id.write_text("#!/bin/sh\nprintf '0\\n'\n")
fake_id.chmod(0o755)
with lock_path.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
result = subprocess.run(
["sh", str(script), "v1.2.3"],
env={
**os.environ,
"PATH": f"{bin_dir}:{os.environ['PATH']}",
"IMPORTARR_ENV_FILE": str(tmp_path / "missing.env"),
"IMPORTARR_PREFIX": str(tmp_path),
},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "another Importarr upgrade is already running" in result.stderr
+7 -15
View File
@@ -453,27 +453,19 @@ def test_worker_failure_stops_retrying_after_limit(tmp_path, monkeypatch):
assert updated["attempt_count"] == main.MAX_RETRY_ATTEMPTS
def test_control_update_runs_configured_command(tmp_path, monkeypatch):
def test_control_update_schedules_configured_command_with_release_tag(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 = []
monkeypatch.setattr(main, "_schedule_update", calls.append)
def fake_run(command, **kwargs):
calls.append((command, kwargs))
return main.subprocess.CompletedProcess(command, 0, stdout="updated", stderr="")
result = main.update_service(expected_tag="0.2.0")
monkeypatch.setattr(main.subprocess, "run", fake_run)
result = main.update_service()
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
assert result["status"] == "update_scheduled"
assert result["command"] == ["upgrade", "now", "0.2.0"]
assert calls == [["upgrade", "now", "0.2.0"]]
def test_control_update_skips_command_when_current(tmp_path, monkeypatch):
@@ -486,7 +478,7 @@ def test_control_update_skips_command_when_current(tmp_path, monkeypatch):
monkeypatch.setattr(main.subprocess, "run", fake_run)
result = main.update_service()
result = main.update_service(expected_tag="v0.2.0")
assert result["status"] == "current"
assert result["command"] == ["upgrade", "now"]
+93
View File
@@ -1,3 +1,6 @@
import pytest
def test_health_contains_build_info(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
@@ -21,6 +24,16 @@ def test_build_date_is_rendered_in_local_time(monkeypatch):
assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00"
def test_build_info_prefers_installed_release_provenance(monkeypatch):
import importarr.build_info as build_info
monkeypatch.setenv("IMPORTARR_VERSION", "v1.2.3")
monkeypatch.setenv("IMPORTARR_GIT_SHA", "abc123")
assert build_info.build_info()["version"] == "v1.2.3"
assert build_info.build_info()["git_sha"] == "abc123"
def test_status_contains_service_configuration(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
@@ -132,3 +145,83 @@ def test_connection_test_rejects_unknown_service(tmp_path, monkeypatch):
)
assert response.status_code == 400
def test_control_endpoints_require_configured_bearer_token(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
monkeypatch.setattr(main.settings, "auth_token", "test-token")
client = TestClient(main.app)
for method, endpoint in (("get", "/api/control/update-check"), ("post", "/api/control/update"), ("post", "/api/control/restart")):
assert getattr(client, method)(endpoint).status_code == 401
assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401
def test_write_endpoint_fails_closed_without_token_on_non_loopback_bind(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
monkeypatch.setattr(main.settings, "auth_token", None)
monkeypatch.setattr(main.settings, "bind_host", "0.0.0.0")
assert TestClient(main.app).post("/api/control/restart").status_code == 503
def test_tokenless_local_development_remains_available(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
monkeypatch.setattr(main.settings, "auth_token", None)
monkeypatch.setattr(main.settings, "bind_host", "127.0.0.1")
main.require_write_auth()
def test_update_schedules_exact_latest_release_tag(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "v1.0.0", "latest_version": "v1.2.3", "update_available": True})
monkeypatch.setattr(main.settings, "update_command", ["/opt/importarr/repo-upgrade.sh"])
scheduled = []
monkeypatch.setattr(main, "_schedule_update", scheduled.append)
response = main.update_service(expected_tag="v1.2.3")
assert scheduled == [["/opt/importarr/repo-upgrade.sh", "v1.2.3"]]
assert response["status"] == "update_scheduled"
def test_update_endpoint_requires_expected_tag(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
monkeypatch.setattr(main.settings, "auth_token", "test-token")
monkeypatch.setattr(main, "check_update_available", lambda: pytest.fail("release lookup must not run"))
response = TestClient(main.app).post(
"/api/control/update",
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "expected_tag"]
def test_update_rejects_unexpected_latest_release(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi import HTTPException
monkeypatch.setattr(main, "check_update_available", lambda: {"latest_version": "v1.2.4", "update_available": True})
scheduled = []
monkeypatch.setattr(main, "_schedule_update", scheduled.append)
with pytest.raises(HTTPException) as exc_info:
main.update_service(expected_tag="v1.2.3")
assert exc_info.value.status_code == 409
assert scheduled == []