feat(deploy): conflict-free 'git pull' update flow (D001/D004/D005)

- deploy.sh: clean-tree guard + git fetch && git pull --ff-only + optional
  dep reinstall + app.db --migrate + restart.
- restart-service.sh: supervisor-agnostic restart (systemd or fallback), waits
  for an in-flight scan.
- check_runtime_git.sh: proves no scan writes git-tracked runtime data.
- ids.service: systemd unit template.
- tests/test_deployment.py: stdlib unittest invariant tests for the deployment
  runtime-isolation guarantee.
This commit is contained in:
zwitschi committed 2026-10-24 19:50:45 +02:00
1 parent 4960eef312
commit 5d81f617f0
6 files changed
+305

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
#
# Deployment support for the intrusion-detection IDS.
#
# This directory is intentionally small and uses no external framework:
# deploy.sh conflict-free `git pull` update wrapper (D004)
# ids.service systemd unit template (D005)
# restart-service.sh supervisor-agnostic restart shim (D005)
# check_runtime_git.sh proves a scan never dirties tracked runtime files (D001)
#
# See .github/instructions/DEPLOYMENT.md for the full plan.
set -euo pipefail
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# D001 — runtime write-path guard.
#
# Proves the deployment invariant: after running a scan, `git status` shows
# NO tracked file modified by the scan — every runtime write lands in a
# gitignored location (data/, reports/, *.db). This is what makes `git pull`
# conflict-free on a server where scans keep running.
#
# If this fails, a scan wrote into a git-tracked path and `git pull` on the
# server can conflict. Investigate the printed files before deploying.
#
# Usage:
# deploy/check_runtime_git.sh [--live]
# --live additionally run a real scan first (requires log sources) and
# then assert the tree is still clean. Without --live it only
# verifies that whatever runtime files already exist are untracked.
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ "${1:-}" == "--live" ]]; then
echo "== Running a real scan to exercise every write path =="
python app.py scan --quiet --dry-run || true
fi
echo "== Checking that runtime data is gitignored / untracked =="
# Paths a scan writes to. Any of these being tracked OR showing as (non-ignored)
# untracked means the invariant is broken and a git pull could conflict.
RUNTIME_PATHS=(
"app/data"
"app/reports"
"app/data/ids.db"
"app/data/ids.db-wal"
"app/data/ids.db-shm"
)
fail=0
# 1) No runtime path may be TRACKED, except the single allowed read-only
# PaloAlto seed. `git ls-files` reflects the index (including staged
# changes), so unborn commits don't cause false failures.
allowed="app/data/paloalto/scanner-ips.txt"
tracked=$(git ls-files app/data app/reports)
problem=$(printf '%s\n' "$tracked" | grep -v '^'"/\$allowed"'$' || true)
if [[ -n "$problem" ]]; then
echo "FAIL: unexpected tracked files under runtime dirs:" >&2
echo "$problem" >&2
fail=1
fi
# 2) Nothing may show as untracked-but-not-ignored under the runtime dirs, i.e.
# a scan wrote a file git would happily add (and later conflict on).
for p in "${RUNTIME_PATHS[@]}"; do
dirty=$(git status --porcelain --untracked-files=all -- "$p" 2>/dev/null || true)
if [[ -n "$dirty" ]]; then
echo "FAIL: runtime path has changes git cares about:" >&2
echo "$dirty" >&2
fail=1
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "OK: no tracked runtime data; git pull will not conflict with scan writes."
else
echo "BROKEN: a scan wrote into git-tracked paths — fix before deploying." >&2
exit 1
fi
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# D004 — conflict-free deployment update wrapper.
#
# Updates a deployed instance with `git pull` WITHOUT ever hitting a merge
# conflict from files a scan wrote since the last pull. Guarantees:
#
# 1. Refuses to update over uncommitted tracked changes (clean-tree guard).
# 2. Fast-forwards only (--ff-only): never creates merge commits and never
# silently conflicts.
# 3. Runtime data (data/, reports/, *.db) is gitignored, so a running scan
# leaves no dirty tracked paths and never blocks the pull.
# 4. Reinstalls dependencies only if requirements.txt changed.
# 5. Applies DB migrations idempotently.
# 6. Restarts the app under its supervisor.
#
# Usage:
# sudo deploy/deploy.sh # update + restart (default env: production)
# ENV=production deploy/deploy.sh # use deploy/ids@production.service
# deploy/deploy.sh --no-restart # update deps+migrate but don't restart
set -euo pipefail
cd "$(dirname "$0")/.."
ENV="${ENV:-production}"
RESTART="${1:-restart}"
ROOT="$(pwd)"
PY="${PY:-python}"
echo "== Intrusion Detection deploy (env=$ENV) =="
# 1) Clean-tree guard. Runtime data is gitignored, so this only fails if a
# human edited tracked source without committing.
if ! git diff --quiet HEAD || ! git diff --cached --quiet; then
echo "REFUSING: working tree is not clean." >&2
echo "Commit or stash tracked changes, then re-run." >&2
echo " git status --short" >&2
exit 1
fi
# 2) Pre-flight: prove runtime isolation holds (fail fast before touching repo).
if "$ROOT/deploy/check_runtime_git.sh"; then
echo "== runtime isolation ok =="
else
echo "ABORT: runtime isolation check failed." >&2
exit 1
fi
# 3) Fast-forward only.
echo "== fetching upstream =="
git fetch origin
echo "== fast-forward pull =="
git pull --ff-only
# 4) Dependencies (only if the requirement set changed since the last pull).
if git diff --quiet HEAD@{1} HEAD -- requirements.txt; then
echo "== requirements.txt unchanged; skipping pip =="
else
echo "== requirements.txt changed; installing deps =="
command -v "$PY" >/dev/null && "$PY" -m pip install -r requirements.txt \
|| pip install -r requirements.txt
fi
# 5) Apply DB migrations (idempotent).
echo "== applying DB migrations =="
"$PY" -m app.db --migrate
# 6) Restart under the supervisor.
if [[ "$RESTART" == "--no-restart" ]]; then
echo "== --no-restart: update complete; restart manually =="
else
echo "== restarting service =="
"$ROOT/deploy/restart-service.sh" "$ENV"
fi
echo "== deploy complete =="
+41
View File
@@ -0,0 +1,41 @@
# systemd unit template (D005) — intrusion-detection web UI.
#
# Install:
# - Replace /path/to/intrusion-detection and the User/Group.
# - Copy to /etc/systemd/system/ids.service
# - sudo systemctl daemon-reload && sudo systemctl enable --now ids
#
# The app binds to 127.0.0.1:<WEB_PORT>; it must NOT be exposed to the LAN.
#
# Restart policy: automatically restart on crash. `deploy/deploy.sh` restarts
# this unit after a successful `git pull`. Scans run in a background thread, so
# one (serialized) scan at a time; deploy waits for the worker to be idle
# (see restart-service.sh) before restarting.
[Unit]
Description=Intrusion Detection web UI + IDS scanner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Run as an unprivileged user; the app only needs read access to the log dirs
# it consumes (COPYPARTY_LOG_PATH / NPM_LOG_PATH) and write access to its own
# data/ + reports/ + ids.db.
User=ids
Group=ids
WorkingDirectory=/path/to/intrusion-detection
EnvironmentFile=-/etc/ids.env
ExecStart=/path/to/intrusion-detection/.venv/bin/python /path/to/intrusion-detection/app.py
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=no
ReadWritePaths=/path/to/intrusion-detection/data /path/to/intrusion-detection/reports /var/lib/ids
[Install]
WantedBy=multi-user.target
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# D005 — supervisor-agnostic restart shim.
#
# Restarts the intrusion-detection service after a deploy, preferring systemd
# (Linux), with a fallback message for other environments (Windows dev).
#
# It first waits for any in-flight background scan to finish so a checkout
# change never interleaves with a live scan (belt-and-suspenders; the runtime
# isolation guarantee already means scans only write gitignored files).
#
# Usage:
# deploy/restart-service.sh [production]
# ENV=production deploy/restart-service.sh
set -euo pipefail
cd "$(dirname "$0")/.."
ENV="${1:-${ENV:-production}}"
UNIT="ids@${ENV}.service"
# Wait for an in-flight scan to finish (~0..120s). We probe the web UI status
# endpoint if it is reachable; a failure to reach it just means no web UI is up.
web_host="${WEB_HOST:-127.0.0.1}"
web_port="${WEB_PORT:-54666}"
status_url="http://${web_host}:${web_port}/api/scan/status"
for _ in $(seq 1 60); do
running=$(curl -fsS --max-time 2 "$status_url" 2>/dev/null \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("running",False))' 2>/dev/null \
|| echo false)
[[ "$running" == "false" ]] && break
echo " waiting for running scan to finish..."
sleep 2
done
if command -v systemctl >/dev/null 2>&1; then
# Installed unit is <unit>.service.template; the enabled name includes env.
if systemctl list-unit-files "${UNIT}" >/dev/null 2>&1; then
echo "== systemctl restart ${UNIT} =="
systemctl restart "${UNIT}"
systemctl --no-pager status "${UNIT}" --lines=0 || true
elif systemctl list-unit-files "ids.service" >/dev/null 2>&1; then
echo "== systemctl restart ids.service =="
systemctl restart ids.service
systemctl --no-pager status ids.service --lines=0 || true
else
echo "No ids systemd unit found; restart manually." >&2
exit 2
fi
else
echo "No systemd available (Windows dev?). Restart the app manually." >&2
echo " python app.py # in a terminal, or via your dev runner" >&2
exit 2
fi