diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..311a967 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy live + +on: + push: + branches: + - main + +concurrency: + group: importarr-live + cancel-in-progress: false + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: | + python -m pip install '.[test]' + npm --prefix frontend ci + - name: Test and build + run: | + python -m pytest -q + npm --prefix frontend test -- --run + npm --prefix frontend run build + sh -n deploy/systemd-install.sh + sh -n deploy/repo-upgrade.sh + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - name: Configure deployment SSH + shell: bash + env: + DEPLOY_KEY: ${{ secrets.IMPORTARR_DEPLOY_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.IMPORTARR_DEPLOY_KNOWN_HOSTS }} + run: | + install -d -m 0700 ~/.ssh + install -m 0600 /dev/null ~/.ssh/id_ed25519 + printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519 + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + + - name: Deploy origin/main + env: + DEPLOY_HOST: ${{ secrets.IMPORTARR_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.IMPORTARR_DEPLOY_PORT }} + DEPLOY_USER: ${{ secrets.IMPORTARR_DEPLOY_USER }} + run: >- + ssh -p "$DEPLOY_PORT" -o BatchMode=yes "$DEPLOY_USER@$DEPLOY_HOST" + sudo -n /bin/sh -c 'set -eu; cd /opt/importarr/repo; + test -z "$(git status --porcelain)"; + git fetch --prune origin main; + git checkout -B main origin/main; + exec /bin/sh deploy/repo-upgrade.sh' diff --git a/.gitignore b/.gitignore index 3551159..5ce76fd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,4 @@ __pycache__/ *.db *.partial AGENTS.local.md -.review-data/ -deploy/importarr.review.env frontend/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index f239836..1e1775b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,7 @@ # Repository Instructions -The Importarr review site at must always reflect the local working-tree code. +## Deployment workflow -- 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=` 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. +Pushes to `main` deploy through the bounded Gitea Actions workflow. Follow the +host-local operational runbook for credentials, live URLs, and verification; +never put tokens, SSH keys, or other secrets in this repository or logs. diff --git a/Makefile b/Makefile index 564e83c..2e13fcf 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,6 @@ 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 @@ -18,7 +17,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 $(RELEASE) + sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh verify: curl -fsS $(IMPORTARR_URL)/health diff --git a/README.md b/README.md index 26c6814..2b67a13 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,26 @@ 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 v1.2.3 +sudo -n sh /opt/importarr/repo-upgrade.sh ``` -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. +The helper requires a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches and checks out exactly `origin/main`, reinstalls the package, records commit provenance, and restarts `importarr.service`. -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. +When bootstrapping an existing tagged-release installation, invoke the new helper directly from the checked-out repository once instead of invoking the old installed copy: -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. +```sh +sudo -n sh /opt/importarr/repo/deploy/repo-upgrade.sh +``` + +That first run installs the repository-based helper at `/opt/importarr/repo-upgrade.sh` and removes the obsolete `IMPORTARR_UPDATE_COMMAND` entry from the existing env file. Subsequent deployments can use the installed helper normally. + +Pushes to `main` run `.gitea/workflows/deploy.yml`. Python tests, frontend tests/build, and deploy-script syntax checks must pass before the deploy job can start. The workflow then connects only to the configured live SSH host, refuses a dirty checkout, updates `/opt/importarr/repo` to `origin/main`, and invokes that checkout's upgrade helper through passwordless sudo. + +Configure repository Actions secrets `IMPORTARR_DEPLOY_HOST`, `IMPORTARR_DEPLOY_PORT`, `IMPORTARR_DEPLOY_USER`, `IMPORTARR_DEPLOY_SSH_KEY` (a dedicated private key accepted for the deployment account), and `IMPORTARR_DEPLOY_KNOWN_HOSTS` (a pinned `known_hosts` line for the live host). The deployment account must allow the workflow's fixed `/bin/sh -c` deployment command through `sudo -n`. The live checkout must be able to fetch `origin/main`; do not put a Git credential in the workflow. Protect `main`: require pull requests and the workflow test job, restrict direct pushes and force pushes, and limit workflow/secret administration to trusted maintainers. Gitea Actions executes repository code before deployment, so deployment secrets must not be exposed to pull-request workflows and untrusted contributors must not be permitted to push to `main`. These controls are repository-administration prerequisites; this workflow cannot enforce them itself. + +The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent deployments cannot overlap. 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 and pushed to `main`; tags remain optional release metadata and are not the deployment trigger. ### Required setup @@ -78,8 +90,6 @@ 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?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` @@ -103,42 +113,6 @@ lucide-react for icons. Run `npm --prefix frontend run dev` for Vite's developme server (it proxies API calls to port 8765), or build before running FastAPI so the production assets are written to `importarr/static`. -### Persistent local review environment - -The review Compose stack builds the current working tree, including uncommitted -UI/API changes, and stays running for pre-commit or pre-push inspection. It is -separate from production: the web port is bound to localhost by default, state and -sample media live under the ignored `.review-data/` directory, external Arr/SAB -services are not required, and service-control/update commands are safe no-ops. - -```sh -deploy/review/review.sh up # build current files and start in background -deploy/review/review.sh update # rebuild changed files and recreate as needed -deploy/review/review.sh status -deploy/review/review.sh logs # follow logs; Ctrl-C leaves the stack running -deploy/review/review.sh stop # stop containers, preserving them and data -deploy/review/review.sh down # remove containers/network, preserving data -deploy/review/review.sh reset # remove stack and all local review data -``` - -On first use the wrapper copies `deploy/importarr.review.env.example` to the -ignored `deploy/importarr.review.env`. Adjust `REVIEW_PORT` there if port 18765 -is occupied, then review `http://127.0.0.1:18765/`. For review from a trusted -internal network, set `REVIEW_BIND_ADDRESS` to the host's LAN address and use -that address in the URL. Do not use `0.0.0.0` or expose this review stack to an -untrusted network; the safe default is `127.0.0.1`. To exercise imports without -real integrations, place disposable folders in `.review-data/downloads/`; movie -and TV destinations are `.review-data/movies/` and `.review-data/tv/`. -Each checkout gets its own Compose project and image name, so worktrees do not -replace each other's containers or images. Read-only and cleanup commands do not -create the local env file when it is absent. - -Agent workflow: run tests, run `update`, confirm `status` reports healthy, and -leave the stack running for the reviewer. Reviewer workflow: inspect the UI and -API, use `logs` when needed, and use `down` after review (or `reset` when the -saved review state is no longer useful). Re-run `update` after every working-tree -change that should be reviewed. - ## Operations Importarr intentionally does not document private deployment topology, hostnames, reverse proxies, monitoring, backups, or operator workflows in this repository. Keep those details in your own ops runbooks. diff --git a/deploy/docker-compose.review.yml b/deploy/docker-compose.review.yml deleted file mode 100644 index de1c424..0000000 --- a/deploy/docker-compose.review.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - importarr: - build: - context: .. - dockerfile: Dockerfile - image: ${REVIEW_IMAGE:-importarr-review:local} - env_file: - - ${REVIEW_SERVICE_ENV_FILE:-importarr.review.env.example} - ports: - - "${REVIEW_BIND_ADDRESS:-127.0.0.1}:${REVIEW_PORT:-18765}:8765" - volumes: - - ../.review-data/config:/config - - ../.review-data/downloads:/data/downloads/manual - - ../.review-data/movies:/data/movies - - ../.review-data/tv:/data/tv - restart: unless-stopped - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=2)"] - interval: 10s - timeout: 3s - retries: 6 - start_period: 5s diff --git a/deploy/importarr.env.example b/deploy/importarr.env.example index 34ebddf..ac49cfd 100644 --- a/deploy/importarr.env.example +++ b/deploy/importarr.env.example @@ -16,6 +16,3 @@ IMPORTARR_LOG_LEVEL=info 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 diff --git a/deploy/importarr.review.env.example b/deploy/importarr.review.env.example deleted file mode 100644 index b0c23f3..0000000 --- a/deploy/importarr.review.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Copied to importarr.review.env by deploy/review/review.sh. No secrets or -# external services are required for review. -REVIEW_PORT=18765 -# Keep this on loopback unless review access from a trusted network is needed. -REVIEW_BIND_ADDRESS=127.0.0.1 -IMPORTARR_BIND_HOST=0.0.0.0 -IMPORTARR_BIND_PORT=8765 -IMPORTARR_STATE_PATH=/config/importarr.db -IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual -IMPORTARR_MOVIES_ROOT=/data/movies -IMPORTARR_TV_ROOT=/data/tv -IMPORTARR_SAB_URL=http://127.0.0.1:9 -IMPORTARR_SAB_CATEGORY=review -IMPORTARR_POLL_SECONDS=3600 -IMPORTARR_LOG_LEVEL=info - -# UI control operations must not control host services or update this checkout. -IMPORTARR_START_COMMAND=/bin/true -IMPORTARR_STOP_COMMAND=/bin/true -IMPORTARR_RESTART_COMMAND=/bin/true -IMPORTARR_UPDATE_COMMAND=/bin/true -IMPORTARR_UPDATE_RELEASE_URL=http://127.0.0.1:9/releases/latest -IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS=1 diff --git a/deploy/repo-upgrade.sh b/deploy/repo-upgrade.sh index 4f71220..e7ece6f 100644 --- a/deploy/repo-upgrade.sh +++ b/deploy/repo-upgrade.sh @@ -2,7 +2,7 @@ set -eu if [ "$(id -u)" -ne 0 ]; then - echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH" >&2 + echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh" >&2 exit 1 fi @@ -16,10 +16,8 @@ PREFIX=${IMPORTARR_PREFIX:-/opt/importarr} 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 +if [ "$#" -ne 0 ]; then + echo "Usage: $0" >&2 exit 2 fi @@ -42,20 +40,19 @@ if [ -n "$(git status --porcelain)" ]; then exit 1 fi -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" +git fetch --prune origin main +git checkout -B main origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" "$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "$REPO_DIR" +if [ -f "$ENV_FILE" ]; then + sed -i '/^[[:space:]]*IMPORTARR_UPDATE_COMMAND=/d' "$ENV_FILE" +fi 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" +VERSION="$(git rev-parse --short=12 HEAD)" BUILD_ENV_TMP="$PREFIX/build.env.tmp.$$" trap 'rm -f "$BUILD_ENV_TMP"' EXIT HUP INT TERM cat > "$BUILD_ENV_TMP" <&2 - exit 2 -} - -command -v docker >/dev/null 2>&1 || { - echo "docker is required" >&2 - exit 1 -} - -if docker info >/dev/null 2>&1; then - docker_with_sudo=false -elif command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then - docker_with_sudo=true -else - echo "cannot access the Docker daemon with docker or sudo -n docker" >&2 - exit 1 -fi - -compose() { - selected_env_file=$env_example - if [ -f "$env_file" ]; then - selected_env_file=$env_file - fi - - if [ "$docker_with_sudo" = true ]; then - sudo -n env REVIEW_IMAGE="$review_image" REVIEW_SERVICE_ENV_FILE="$selected_env_file" \ - docker compose --project-name "$project_name" --project-directory "$repo_dir/deploy" \ - --env-file "$selected_env_file" -f "$compose_file" "$@" - else - REVIEW_IMAGE="$review_image" REVIEW_SERVICE_ENV_FILE="$selected_env_file" \ - docker compose --project-name "$project_name" --project-directory "$repo_dir/deploy" \ - --env-file "$selected_env_file" -f "$compose_file" "$@" - fi -} - -ensure_env_file() { - if [ ! -f "$env_file" ]; then - cp "$env_example" "$env_file" - echo "Created $env_file from the safe review defaults." - fi -} - -remove_data() { - if rm -rf "$data_dir" 2>/dev/null && [ ! -e "$data_dir" ]; then - return - fi - if [ "$docker_with_sudo" = true ]; then - sudo -n rm -rf "$data_dir" - else - echo "cannot remove root-owned review data without passwordless sudo: $data_dir" >&2 - exit 1 - fi -} - -case "${1:-}" in - up|update) - ensure_env_file - mkdir -p "$data_dir/config" "$data_dir/downloads" "$data_dir/movies" "$data_dir/tv" - compose up --detach --build --wait - ;; - status) - compose ps - container_id=$(compose ps --quiet importarr) - [ -n "$container_id" ] || { - echo "review service is not running" >&2 - exit 1 - } - health=$(if [ "$docker_with_sudo" = true ]; then - sudo -n docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" - else - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" - fi) - [ "$health" = healthy ] || { - echo "review service is not healthy (status: $health)" >&2 - exit 1 - } - ;; - logs) - compose logs --follow --tail=200 - ;; - stop) - compose stop - ;; - down) - compose down --remove-orphans - ;; - reset) - compose down --remove-orphans - remove_data - echo "Removed review data: $data_dir" - ;; - *) usage ;; -esac diff --git a/deploy/systemd-install.sh b/deploy/systemd-install.sh index 088a299..42bd783 100644 --- a/deploy/systemd-install.sh +++ b/deploy/systemd-install.sh @@ -46,4 +46,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 vMAJOR.MINOR.PATCH" +echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh" diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index a3ed86b..81ad20c 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react"; +import { Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react"; import "./globals.css"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui"; @@ -36,18 +36,17 @@ export function SettingsDialog({ status, refresh }) { function ManualBatches({ refresh }) { const [batches,setBatches]=useState([]); const load=()=>request("/api/manual-batches").then(setBatches).catch(()=>{}); return Manual batches}>
{e.preventDefault();try{await request("/api/manual-batches",{method:"POST",body:{path:e.currentTarget.path.value}});e.currentTarget.reset();load();refresh()}catch(error){alert(error.message)}}}>
{batches.length?batches.map(b=>
#{b.id} · {b.status}

{b.path}

):

No manual batches.

}
; } export function App(){ - const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [update,setUpdate]=useState(null), [menu,setMenu]=useState(false); + const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [menu,setMenu]=useState(false); const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches)); const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } }; useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]); - useEffect(()=>{let stopped=false,lastCurrent,lastJobs=0,failures=0;const poll=async()=>{try{const s=await request("/api/status");if(stopped)return;setStatus(s);const current=typeof s.current==="object"?(s.current.file||s.current.name):s.current;if(current!==lastCurrent||Date.now()-lastJobs>(current?10000:2000)){lastCurrent=current;lastJobs=Date.now();const j=await request("/api/jobs");setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})))}failures=0}catch(error){failures++;console.error(error)}if(!stopped)setTimeout(poll,pollDelay(failures))};poll();request("/api/control/update-check",{auth:true}).then(setUpdate).catch(()=>{});return()=>{stopped=true}},[]); + useEffect(()=>{let stopped=false,lastCurrent,lastJobs=0,failures=0;const poll=async()=>{try{const s=await request("/api/status");if(stopped)return;setStatus(s);const current=typeof s.current==="object"?(s.current.file||s.current.name):s.current;if(current!==lastCurrent||Date.now()-lastJobs>(current?10000:2000)){lastCurrent=current;lastJobs=Date.now();const j=await request("/api/jobs");setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})))}failures=0}catch(error){failures++;console.error(error)}if(!stopped)setTimeout(poll,pollDelay(failures))};poll();return()=>{stopped=true}},[]); const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}}; const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)}; const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current; return

Importarr

{status?.control?.queue_mode||"Connecting"}

{currentName||"No active import"}
{menu&& Service info}>
{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])=>
{k}
{String(v)}
)}
}
- {update?.update_available&&Version {update.latest_version} is available}
{[["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])=>{value??"—"}{label})}
Current import

{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}

{currentName||"No active copy."}

{currentName&&<>

{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}

}
Queue and history

Grouped by processing state

{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?

{labels[group]} {items.length}

{items.map(j=>
{j.name}

{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}

{j.state}{j.reason}
{j.can_run_now&&}{j.can_retry&&}{j.can_ignore&&}{j.can_remove&&}
)}
:null}):

No queue items.

}
diff --git a/frontend/src/main.test.jsx b/frontend/src/main.test.jsx index 27a4dc4..71f10ac 100644 --- a/frontend/src/main.test.jsx +++ b/frontend/src/main.test.jsx @@ -35,13 +35,6 @@ describe("Importarr UI behavior", () => { expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) })); }); - it("authenticates the protected update-check GET", async () => { - fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ update_available: false }) }); - vi.stubGlobal("prompt", vi.fn(() => "browser-token")); - await request("/api/control/update-check", { auth: true }); - expect(fetch).toHaveBeenLastCalledWith("/api/control/update-check", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) })); - }); - it("backs polling off after failures and immediately recovers its fast interval", () => { expect([pollDelay(0),pollDelay(1),pollDelay(2),pollDelay(4)]).toEqual([300,1000,2000,5000]); }); diff --git a/importarr/config.py b/importarr/config.py index ab95d1a..8c7542e 100644 --- a/importarr/config.py +++ b/importarr/config.py @@ -25,9 +25,6 @@ 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: ["/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) bind_host: str = "127.0.0.1" bind_port: int = 8765 @@ -53,9 +50,6 @@ 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", ["/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")), bind_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"), bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")), diff --git a/importarr/main.py b/importarr/main.py index 0ecb9c3..bb6159e 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -60,14 +60,6 @@ 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 @@ -338,85 +330,6 @@ 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(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": ""} - command = [*settings.update_command, str(update["latest_version"])] - _schedule_update(command) - return {**update, "status": "update_scheduled", "command": command} - - -@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 _schedule_update(command: list[str]) -> None: - if not command: - raise HTTPException(status_code=500, detail="update command is not configured") - try: - result = subprocess.run( - ["systemd-run", "--unit=importarr-update", "--collect", "--no-block", "--on-active=2s", "--", *command], - check=False, - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode != 0: - raise HTTPException(status_code=500, detail=f"update command failed to schedule: {result.stderr[-1000:]}") - except subprocess.TimeoutExpired as exc: - raise HTTPException(status_code=504, detail="update command timed out while scheduling") from exc - 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") diff --git a/tests/test_config.py b/tests/test_config.py index 40b9bb4..a61d1e2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,9 +15,3 @@ 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"] diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py index dd5ba8e..2d11562 100644 --- a/tests/test_deploy_scripts.py +++ b/tests/test_deploy_scripts.py @@ -18,7 +18,7 @@ def test_repo_upgrade_refuses_concurrent_run(tmp_path): 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"], + ["sh", str(script)], env={ **os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}", @@ -57,3 +57,17 @@ def test_repo_upgrade_forces_fresh_install_without_skipping_dependencies(): assert "pip\" install --upgrade --force-reinstall --no-cache-dir" in script assert "--no-deps" not in script + + +def test_repo_upgrade_deploys_only_origin_main(): + script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text() + + assert "git fetch --prune origin main" in script + assert "git checkout -B main origin/main" in script + assert "refs/tags" not in script + + +def test_repo_upgrade_removes_obsolete_update_command(): + script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text() + + assert "IMPORTARR_UPDATE_COMMAND=/d" in script diff --git a/tests/test_queue_controls.py b/tests/test_queue_controls.py index 9c03595..24e8413 100644 --- a/tests/test_queue_controls.py +++ b/tests/test_queue_controls.py @@ -453,74 +453,6 @@ def test_worker_failure_stops_retrying_after_limit(tmp_path, monkeypatch): assert updated["attempt_count"] == main.MAX_RETRY_ATTEMPTS -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) - - result = main.update_service(expected_tag="0.2.0") - - 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): - 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(expected_tag="v0.2.0") - - 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"] diff --git a/tests/test_status.py b/tests/test_status.py index f353621..429f34e 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -69,7 +69,7 @@ def test_frontend_uses_required_stack_and_capabilities(): assert "lucide-react" in package assert "@radix-ui/react-dialog" in package assert "tailwindcss" in package - for endpoint in ("/api/jobs", "/api/settings", "/api/manual-batches", "/api/control/update", "/api/import/run-now"): + for endpoint in ("/api/jobs", "/api/settings", "/api/manual-batches", "/api/import/run-now"): assert endpoint in app @@ -156,7 +156,7 @@ def test_control_endpoints_require_configured_bearer_token(tmp_path, monkeypatch 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")): + for method, endpoint in (("post", "/api/control/restart"),): assert getattr(client, method)(endpoint).status_code == 401 assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401 @@ -178,76 +178,3 @@ def test_tokenless_local_development_remains_available(tmp_path, monkeypatch): 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_is_scheduled_outside_service_cgroup(tmp_path, monkeypatch): - monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) - import importarr.main as main - - calls = [] - - def fake_run(command, **kwargs): - calls.append((command, kwargs)) - return main.subprocess.CompletedProcess(command, 0, stdout="Running as unit", stderr="") - - monkeypatch.setattr(main.subprocess, "run", fake_run) - main._schedule_update(["/bin/sh", "/opt/importarr/repo-upgrade.sh", "v1.2.3"]) - - assert calls[0][0] == [ - "systemd-run", - "--unit=importarr-update", - "--collect", - "--no-block", - "--on-active=2s", - "--", - "/bin/sh", - "/opt/importarr/repo-upgrade.sh", - "v1.2.3", - ] - - -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 == []