From 8a226da3020f29a6342c55d34fbb0fba783e47b8 Mon Sep 17 00:00:00 2001 From: "protostatis.dev" Date: Sat, 15 Aug 2026 21:18:37 -0500 Subject: [PATCH 1/2] fix: protect trending scout image during deploy --- deploy/release.sh | 55 +++++++++++++++++++++++++++++++++++ tests/test_deploy_workflow.py | 23 +++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/deploy/release.sh b/deploy/release.sh index 7376d10..98b3b21 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -42,11 +42,65 @@ if [ ! -s /opt/crypto-sentiment/data/orchestrator_state.json ]; then exit 1 fi +TRENDING_SCOUT_IMAGE="${TRENDING_SCOUT_IMAGE:-panicradar-trending-scout:latest}" +TRENDING_SCOUT_PIN_NAME="${TRENDING_SCOUT_PIN_NAME:-panicradar-trending-scout-image-pin}" +TRENDING_SCOUT_PIN_NEXT_NAME="${TRENDING_SCOUT_PIN_NAME}-next" +TRENDING_SCOUT_IMAGE_ID="" + +# The scout runs from root's hourly cron and is not built by this release. Pin +# the exact image ID used by its mutable tag before broad Docker cleanup so an +# unrelated application release cannot remove the scheduler's local image. +refresh_trending_scout_pin() { + if ! docker image inspect "$TRENDING_SCOUT_IMAGE" >/dev/null 2>&1; then + echo "ERROR: Required trending scout image is missing: $TRENDING_SCOUT_IMAGE" + echo "Restore the image before deploying; proceeding would leave hourly trends stale." + exit 1 + fi + + TRENDING_SCOUT_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$TRENDING_SCOUT_IMAGE") + + # Create and verify the replacement first so the current image stays + # protected even while an obsolete pin is replaced. + docker rm -f "$TRENDING_SCOUT_PIN_NEXT_NAME" >/dev/null 2>&1 || true + docker run -d --name "$TRENDING_SCOUT_PIN_NEXT_NAME" --restart unless-stopped \ + --label panicradar.role=trending-scout-image-pin \ + --entrypoint /bin/sh "$TRENDING_SCOUT_IMAGE_ID" \ + -c 'while :; do sleep 3600; done' >/dev/null + + PINNED_NEXT_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$TRENDING_SCOUT_PIN_NEXT_NAME") + if [ "$PINNED_NEXT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ]; then + docker rm -f "$TRENDING_SCOUT_PIN_NEXT_NAME" >/dev/null 2>&1 || true + echo "ERROR: Trending scout pin did not retain the expected image ID" + exit 1 + fi + + docker rm -f "$TRENDING_SCOUT_PIN_NAME" >/dev/null 2>&1 || true + docker rename "$TRENDING_SCOUT_PIN_NEXT_NAME" "$TRENDING_SCOUT_PIN_NAME" + echo "Pinned trending scout image: $TRENDING_SCOUT_IMAGE_ID" +} + +verify_trending_scout_pin() { + if ! docker image inspect "$TRENDING_SCOUT_IMAGE" >/dev/null 2>&1; then + echo "ERROR: Trending scout image was removed during cleanup: $TRENDING_SCOUT_IMAGE" + exit 1 + fi + + CURRENT_SCOUT_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$TRENDING_SCOUT_IMAGE") + PINNED_SCOUT_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$TRENDING_SCOUT_PIN_NAME") + if [ "$CURRENT_SCOUT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ] || \ + [ "$PINNED_SCOUT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ]; then + echo "ERROR: Trending scout pin no longer protects the scheduled image" + exit 1 + fi +} + # ========== DISK SPACE CHECK & CLEANUP ========== echo "Pre-cleanup disk usage:" df -h / +refresh_trending_scout_pin docker system prune -af || true docker builder prune -af || true +verify_trending_scout_pin echo "Post-cleanup disk usage:" AVAIL_KB=$(df -kP / | awk 'NR == 2 {print $4}') @@ -489,6 +543,7 @@ fi # Stopped rollback containers still protect their known-good images here. echo "Cleaning up unused images..." docker image prune -af +verify_trending_scout_pin df -h / # ========== VERIFY DEPLOYMENT ========== diff --git a/tests/test_deploy_workflow.py b/tests/test_deploy_workflow.py index a34081e..bc01c13 100644 --- a/tests/test_deploy_workflow.py +++ b/tests/test_deploy_workflow.py @@ -33,3 +33,26 @@ def test_release_script_is_shell_parseable_and_expression_free() -> None: capture_output=True, text=True, ) + + +def test_release_refreshes_and_verifies_the_trending_scout_pin() -> None: + release_script = RELEASE_SCRIPT_PATH.read_text() + + assert "TRENDING_SCOUT_IMAGE" in release_script + assert "TRENDING_SCOUT_IMAGE_ID=$(docker image inspect" in release_script + assert 'docker run -d --name "$TRENDING_SCOUT_PIN_NEXT_NAME"' in release_script + assert 'docker inspect --format \'{{.Image}}\' "$TRENDING_SCOUT_PIN_NAME"' in release_script + + refresh_call = release_script.index("\nrefresh_trending_scout_pin\n") + system_prune = release_script.index("docker system prune -af") + post_deploy_prune = release_script.index("docker image prune -af") + verify_after_system_prune = release_script.index( + "\nverify_trending_scout_pin\n", system_prune + ) + verify_after_post_deploy_prune = release_script.index( + "\nverify_trending_scout_pin\n", post_deploy_prune + ) + + assert refresh_call < system_prune + assert verify_after_system_prune < post_deploy_prune + assert verify_after_post_deploy_prune > post_deploy_prune From ed05892cf976b24e77c8edc0b119937329e932fc Mon Sep 17 00:00:00 2001 From: "protostatis.dev" Date: Sat, 15 Aug 2026 21:24:47 -0500 Subject: [PATCH 2/2] fix: harden trending scout image pin --- deploy/release.sh | 20 ++++++++++++++++---- tests/test_deploy_workflow.py | 3 +++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/deploy/release.sh b/deploy/release.sh index 98b3b21..77d4751 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -63,14 +63,20 @@ refresh_trending_scout_pin() { # protected even while an obsolete pin is replaced. docker rm -f "$TRENDING_SCOUT_PIN_NEXT_NAME" >/dev/null 2>&1 || true docker run -d --name "$TRENDING_SCOUT_PIN_NEXT_NAME" --restart unless-stopped \ - --label panicradar.role=trending-scout-image-pin \ + --memory 8m --cpus 0.01 \ --entrypoint /bin/sh "$TRENDING_SCOUT_IMAGE_ID" \ -c 'while :; do sleep 3600; done' >/dev/null + if ! docker inspect "$TRENDING_SCOUT_PIN_NEXT_NAME" >/dev/null 2>&1; then + echo "ERROR: Replacement trending scout pin container was not created" + exit 1 + fi PINNED_NEXT_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$TRENDING_SCOUT_PIN_NEXT_NAME") - if [ "$PINNED_NEXT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ]; then + PINNED_NEXT_RUNNING=$(docker inspect --format '{{.State.Running}}' "$TRENDING_SCOUT_PIN_NEXT_NAME") + if [ "$PINNED_NEXT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ] || \ + [ "$PINNED_NEXT_RUNNING" != "true" ]; then docker rm -f "$TRENDING_SCOUT_PIN_NEXT_NAME" >/dev/null 2>&1 || true - echo "ERROR: Trending scout pin did not retain the expected image ID" + echo "ERROR: Replacement trending scout pin is not running on the expected image ID" exit 1 fi @@ -86,9 +92,15 @@ verify_trending_scout_pin() { fi CURRENT_SCOUT_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$TRENDING_SCOUT_IMAGE") + if ! docker inspect "$TRENDING_SCOUT_PIN_NAME" >/dev/null 2>&1; then + echo "ERROR: Trending scout pin container is missing: $TRENDING_SCOUT_PIN_NAME" + exit 1 + fi PINNED_SCOUT_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$TRENDING_SCOUT_PIN_NAME") + PINNED_SCOUT_RUNNING=$(docker inspect --format '{{.State.Running}}' "$TRENDING_SCOUT_PIN_NAME") if [ "$CURRENT_SCOUT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ] || \ - [ "$PINNED_SCOUT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ]; then + [ "$PINNED_SCOUT_IMAGE_ID" != "$TRENDING_SCOUT_IMAGE_ID" ] || \ + [ "$PINNED_SCOUT_RUNNING" != "true" ]; then echo "ERROR: Trending scout pin no longer protects the scheduled image" exit 1 fi diff --git a/tests/test_deploy_workflow.py b/tests/test_deploy_workflow.py index bc01c13..b5ea31b 100644 --- a/tests/test_deploy_workflow.py +++ b/tests/test_deploy_workflow.py @@ -41,7 +41,10 @@ def test_release_refreshes_and_verifies_the_trending_scout_pin() -> None: assert "TRENDING_SCOUT_IMAGE" in release_script assert "TRENDING_SCOUT_IMAGE_ID=$(docker image inspect" in release_script assert 'docker run -d --name "$TRENDING_SCOUT_PIN_NEXT_NAME"' in release_script + assert "--memory 8m --cpus 0.01" in release_script + assert "--label panicradar.role=trending-scout-image-pin" not in release_script assert 'docker inspect --format \'{{.Image}}\' "$TRENDING_SCOUT_PIN_NAME"' in release_script + assert "Trending scout pin container is missing" in release_script refresh_call = release_script.index("\nrefresh_trending_scout_pin\n") system_prune = release_script.index("docker system prune -af")