Harden live release workflow #25
This commit is contained in:
@@ -5,3 +5,7 @@ The Importarr review site at <http://172.20.30.35:18765/> must always reflect th
|
||||
- After making local code changes, run `deploy/review/review.sh update` from the repository root.
|
||||
- Before finishing, verify that `http://172.20.30.35:18765/` responds successfully.
|
||||
- If deployment or URL verification fails, report the failure clearly; do not claim the review site is current.
|
||||
|
||||
## Release workflow
|
||||
|
||||
Generic requests such as "release app", "release to live", or "release importarr" mean the complete release-to-live flow: verify a clean release candidate, run tests, commit the intended changes, create and push a SemVer tag and repository release, call the live bearer's protected `POST /api/control/update?expected_tag=<release-tag>` with the required exact release tag, then verify health and that the reported installed version matches the release. Follow any host-local operational runbook for credentials and live URLs; never put tokens or other secrets in this repository, commands shown in logs, release notes, or commits.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY importarr ./importarr
|
||||
COPY --from=frontend /build/importarr/static ./importarr/static
|
||||
ARG IMPORTARR_VERSION=0.1.0
|
||||
ARG IMPORTARR_VERSION=0.1.1
|
||||
ARG IMPORTARR_BUILD_DATE=unknown
|
||||
ARG IMPORTARR_GIT_SHA=unknown
|
||||
ENV IMPORTARR_VERSION=$IMPORTARR_VERSION \
|
||||
|
||||
@@ -4,6 +4,7 @@ SERVICE ?= importarr.service
|
||||
IMPORTARR_PREFIX ?= /opt/importarr
|
||||
IMPORTARR_REPO_DIR ?= $(CURDIR)
|
||||
IMPORTARR_URL ?= http://127.0.0.1:8765
|
||||
RELEASE ?= vMAJOR.MINOR.PATCH
|
||||
|
||||
.PHONY: test install-systemd install-from-repo repo-upgrade verify
|
||||
|
||||
@@ -17,7 +18,7 @@ install-from-repo:
|
||||
sudo -n $(IMPORTARR_PREFIX)/venv/bin/pip install --upgrade $(IMPORTARR_REPO_DIR)
|
||||
|
||||
repo-upgrade:
|
||||
sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh
|
||||
sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh $(RELEASE)
|
||||
|
||||
verify:
|
||||
curl -fsS $(IMPORTARR_URL)/health
|
||||
|
||||
@@ -38,12 +38,12 @@ The installer creates the `importarr` system user when needed, installs a virtua
|
||||
For a machine that should stay current with the repository, use the installed repo-upgrade helper:
|
||||
|
||||
```sh
|
||||
sudo -n sh /opt/importarr/repo-upgrade.sh
|
||||
sudo -n sh /opt/importarr/repo-upgrade.sh v1.2.3
|
||||
```
|
||||
|
||||
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 requires the intended release tag and a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches tags, checks out that exact tag in detached-HEAD state, reinstalls the package, records tag/SHA provenance, and restarts `importarr.service`; it never installs an arbitrary branch head.
|
||||
|
||||
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. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need 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 installed release recorded in `/opt/importarr/build.env`. `POST /api/control/update?expected_tag=v1.2.3` verifies that the latest release is the caller's expected tag, appends that exact tag to the configured command, and schedules it in a detached process after returning. A mismatch returns `409` without scheduling an upgrade. The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent requests cannot overlap. Poll `/health` and `update-check` until the new process is healthy and reports the expected tag. The systemd default is the working-directory-independent `/bin/sh /opt/importarr/repo-upgrade.sh`; configure `IMPORTARR_UPDATE_COMMAND` only when the helper is installed elsewhere. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need 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.
|
||||
|
||||
@@ -53,7 +53,7 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
|
||||
2. Set its completed folder to the same path mounted as `IMPORTARR_DOWNLOAD_ROOT`.
|
||||
3. Set `IMPORTARR_SAB_URL` and `IMPORTARR_SAB_API_KEY_FILE` or `IMPORTARR_SAB_API_KEY`.
|
||||
4. Mount/configure `IMPORTARR_MOVIES_ROOT` and `IMPORTARR_TV_ROOT` read/write.
|
||||
5. Set `IMPORTARR_AUTH_TOKEN` unless write endpoints are protected by a reverse proxy.
|
||||
5. Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` for every non-loopback bind. Importarr fails closed on write/control endpoints when a non-loopback service has no token; tokenless local development remains available on `127.0.0.1`, `::1`, or `localhost`.
|
||||
6. Check `GET /health`, then inspect `/api/preview` before running imports.
|
||||
|
||||
## Safety model
|
||||
@@ -79,7 +79,7 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
|
||||
- `POST /api/control/cancel-current`
|
||||
- `POST /api/control/restart`
|
||||
- `GET /api/control/update-check`
|
||||
- `POST /api/control/update`
|
||||
- `POST /api/control/update?expected_tag=v1.2.3` (`expected_tag` is required)
|
||||
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
|
||||
- `POST /api/import/run-now`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
importarr:
|
||||
image: ghcr.io/OWNER/importarr:0.1.0
|
||||
image: ghcr.io/OWNER/importarr:0.1.1
|
||||
env_file: importarr.env
|
||||
ports:
|
||||
- "8765:8765"
|
||||
|
||||
@@ -17,3 +17,5 @@ IMPORTARR_BIND_HOST=0.0.0.0
|
||||
IMPORTARR_BIND_PORT=8765
|
||||
IMPORTARR_POLL_SECONDS=60
|
||||
# IMPORTARR_REPO_DIR=/path/to/importarr
|
||||
# Override only when the installed helper is not at the standard systemd path.
|
||||
# IMPORTARR_UPDATE_COMMAND=/bin/sh /opt/importarr/repo-upgrade.sh
|
||||
|
||||
+25
-4
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh" >&2
|
||||
echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -13,9 +13,22 @@ if [ -f "$ENV_FILE" ]; then
|
||||
fi
|
||||
|
||||
PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
|
||||
REPO_DIR=${IMPORTARR_REPO_DIR:-$(pwd)}
|
||||
REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo}
|
||||
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
|
||||
VENV=${IMPORTARR_VENV:-$PREFIX/venv}
|
||||
RELEASE_TAG=${1:-}
|
||||
|
||||
if [ -z "$RELEASE_TAG" ]; then
|
||||
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LOCK_FILE=${IMPORTARR_UPGRADE_LOCK_FILE:-$PREFIX/repo-upgrade.lock}
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Refusing to upgrade: another Importarr upgrade is already running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
echo "Importarr repo not found at $REPO_DIR" >&2
|
||||
@@ -29,14 +42,22 @@ if [ -n "$(git status --porcelain)" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --prune origin
|
||||
git pull --ff-only
|
||||
git fetch --prune --tags origin
|
||||
if ! git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then
|
||||
echo "Release tag not found: $RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "$RELEASE_TAG"
|
||||
test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG"
|
||||
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
|
||||
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
||||
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" "$PREFIX/repo-upgrade.sh"
|
||||
systemctl daemon-reload
|
||||
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
VERSION="$RELEASE_TAG"
|
||||
cat > "$PREFIX/build.env" <<EOF
|
||||
IMPORTARR_VERSION=$VERSION
|
||||
IMPORTARR_GIT_SHA=$GIT_SHA
|
||||
IMPORTARR_BUILD_DATE=$BUILD_DATE
|
||||
EOF
|
||||
|
||||
@@ -16,17 +16,23 @@ if [ ! -f /etc/importarr/importarr.env ]; then
|
||||
install -m 0600 "$REPO_DIR/deploy/importarr.env.example" /etc/importarr/importarr.env
|
||||
echo "Created /etc/importarr/importarr.env; edit it before starting the service."
|
||||
fi
|
||||
if ! grep -q '^IMPORTARR_REPO_DIR=' /etc/importarr/importarr.env; then
|
||||
printf '\nIMPORTARR_REPO_DIR=%s\n' "$REPO_DIR" >> /etc/importarr/importarr.env
|
||||
fi
|
||||
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
||||
install -m 0644 "$REPO_DIR/deploy/manual-media-import.service" /etc/systemd/system/manual-media-import.service
|
||||
install -m 0644 "$REPO_DIR/deploy/manual-media-import.timer" /etc/systemd/system/manual-media-import.timer
|
||||
install -m 0644 "$REPO_DIR/deploy/manual-media-import-failure.service" /etc/systemd/system/manual-media-import-failure.service
|
||||
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh
|
||||
if [ "$REPO_DIR" != /opt/importarr/repo ]; then
|
||||
if [ -e /opt/importarr/repo ] && [ ! -L /opt/importarr/repo ]; then
|
||||
echo "Refusing to replace existing /opt/importarr/repo; move it or set IMPORTARR_REPO_DIR explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -sfn "$REPO_DIR" /opt/importarr/repo
|
||||
fi
|
||||
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
VERSION="$(git -C "$REPO_DIR" describe --tags --exact-match HEAD 2>/dev/null || /opt/importarr/venv/bin/python -c 'from importarr import __version__; print(__version__)')"
|
||||
cat > /opt/importarr/build.env <<EOF
|
||||
IMPORTARR_VERSION=$VERSION
|
||||
IMPORTARR_GIT_SHA=$GIT_SHA
|
||||
IMPORTARR_BUILD_DATE=$BUILD_DATE
|
||||
EOF
|
||||
@@ -35,4 +41,4 @@ systemctl enable importarr.service
|
||||
systemctl enable manual-media-import.timer
|
||||
systemctl restart importarr.service
|
||||
systemctl start manual-media-import.timer
|
||||
echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh"
|
||||
echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "importarr-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "importarr-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "importarr-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -41,7 +41,7 @@ export function App(){
|
||||
return <div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header>
|
||||
<main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6">
|
||||
{update?.update_available&&<Card className="border-secondary"><CardContent className="flex flex-wrap items-center gap-3 p-4"><AlertTriangle className="text-accent"/><strong>Version {update.latest_version} is available</strong><Button className="ml-auto" onClick={()=>confirm("Update Importarr now?")&&post("/api/control/update")}>Update now</Button></CardContent></Card>}
|
||||
{update?.update_available&&<Card className="border-secondary"><CardContent className="flex flex-wrap items-center gap-3 p-4"><AlertTriangle className="text-accent"/><strong>Version {update.latest_version} is available</strong><Button className="ml-auto" onClick={()=>confirm("Update Importarr now?")&&post(`/api/control/update?expected_tag=${encodeURIComponent(update.latest_version)}`)}>Update now</Button></CardContent></Card>}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div>
|
||||
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/>{currentName&&<p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p>}</CardContent></Card>
|
||||
<Card><CardHeader className="flex-row items-center gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card>
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ class Settings(BaseModel):
|
||||
start_command: list[str] = Field(default_factory=lambda: ["systemctl", "start", "manual-media-import.service"])
|
||||
stop_command: list[str] = Field(default_factory=lambda: ["systemctl", "stop", "manual-media-import.service"])
|
||||
restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "manual-media-import.service"])
|
||||
update_command: list[str] = Field(default_factory=lambda: ["sh", "deploy/repo-upgrade.sh"])
|
||||
update_command: list[str] = Field(default_factory=lambda: ["/bin/sh", "/opt/importarr/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)
|
||||
@@ -53,7 +53,7 @@ class Settings(BaseModel):
|
||||
start_command=_env_command("IMPORTARR_START_COMMAND", ["systemctl", "start", "manual-media-import.service"]),
|
||||
stop_command=_env_command("IMPORTARR_STOP_COMMAND", ["systemctl", "stop", "manual-media-import.service"]),
|
||||
restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "manual-media-import.service"]),
|
||||
update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["sh", "deploy/repo-upgrade.sh"]),
|
||||
update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["/bin/sh", "/opt/importarr/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")),
|
||||
|
||||
+27
-4
@@ -95,7 +95,9 @@ load_ui_settings()
|
||||
|
||||
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
||||
if not settings.auth_token:
|
||||
return
|
||||
if settings.bind_host in {"127.0.0.1", "::1", "localhost"}:
|
||||
return
|
||||
raise HTTPException(status_code=503, detail="write endpoints require IMPORTARR_AUTH_TOKEN on a non-loopback bind")
|
||||
if authorization != f"Bearer {settings.auth_token}":
|
||||
raise HTTPException(status_code=401, detail="write endpoint requires bearer token")
|
||||
|
||||
@@ -336,12 +338,18 @@ 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]:
|
||||
def update_service(expected_tag: str, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||
update = check_update_available()
|
||||
if update["latest_version"] != expected_tag:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"latest release {update['latest_version']} does not match expected tag {expected_tag}",
|
||||
)
|
||||
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}
|
||||
command = [*settings.update_command, str(update["latest_version"])]
|
||||
_schedule_update(command)
|
||||
return {**update, "status": "update_scheduled", "command": command}
|
||||
|
||||
|
||||
@app.get("/api/control/update-check")
|
||||
@@ -389,6 +397,21 @@ def _version_key(value: str) -> tuple[int, ...] | None:
|
||||
return tuple(int(part) for part in parts)
|
||||
|
||||
|
||||
def _schedule_update(command: list[str]) -> None:
|
||||
if not command:
|
||||
raise HTTPException(status_code=500, detail="update command is not configured")
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["/bin/sh", "-c", 'sleep 1; exec "$@"', "importarr-update", *command],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"update command failed to start: {exc.__class__.__name__}") from exc
|
||||
|
||||
|
||||
def _run_control_command(command: list[str]) -> dict[str, object]:
|
||||
if not command:
|
||||
raise HTTPException(status_code=500, detail="control command is not configured")
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "importarr"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "Arr-style manual SABnzbd import service"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user