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:
1 parent
4960eef312
commit
5d81f617f0
6 files changed
+305
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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 =="
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user