diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index 5529d33..945d15b 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -9,6 +9,8 @@ on: - 'scripts/*studio*' - 'compose.creative-review.yml' - 'scripts/integration.sh' + - 'scripts/cleanup-integration.sh' + - 'scripts/test-integration-cleanup.py' concurrency: group: creative-workspace-${{ gitea.event.pull_request.number }} cancel-in-progress: true @@ -21,7 +23,10 @@ jobs: with: fetch-depth: 0 - name: Check patch formatting - run: git diff --check HEAD^ HEAD + run: | + git diff --check HEAD^ HEAD + bash -n scripts/integration.sh scripts/cleanup-integration.sh + python3 scripts/test-integration-cleanup.py - name: Verify Studio types, tests, build and smoke run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" . - name: Build Wiki integration and runtime images @@ -35,3 +40,8 @@ jobs: WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }} GITEA_RUN_ID: ${{ gitea.run_id }} run: bash scripts/integration.sh + - name: Clean this run disposable integration resources even after cancellation + if: always() + env: + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/cleanup-integration.sh diff --git a/.gitea/workflows/waterfall-wiki.yml b/.gitea/workflows/waterfall-wiki.yml index 5172da9..dba9b29 100644 --- a/.gitea/workflows/waterfall-wiki.yml +++ b/.gitea/workflows/waterfall-wiki.yml @@ -16,6 +16,8 @@ on: - 'scripts/infra.sh' - 'scripts/auth-admin.sh' - 'scripts/integration.sh' + - 'scripts/cleanup-integration.sh' + - 'scripts/test-integration-cleanup.py' - 'pnpm-workspace.yaml' jobs: @@ -29,6 +31,8 @@ jobs: fetch-depth: 0 - name: Check shared content and documentation contracts run: | + python3 scripts/test-integration-cleanup.py + bash -n scripts/integration.sh scripts/cleanup-integration.sh node scripts/check-studio-contracts.mjs node --check scripts/export-studio-series.cjs node --check scripts/save-studio-series.mjs @@ -60,3 +64,8 @@ jobs: - name: Remove temporary acceptance output after upload if: always() && steps.acceptance_evidence.outcome == 'success' run: rm -rf artifacts/studio-acceptance + - name: Clean this run disposable integration resources even after cancellation + if: always() + env: + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/cleanup-integration.sh diff --git a/scripts/cleanup-integration.sh b/scripts/cleanup-integration.sh new file mode 100644 index 0000000..fce51a7 --- /dev/null +++ b/scripts/cleanup-integration.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +: "${GITEA_RUN_ID:?A numeric CI run ID is required}" +[[ "$GITEA_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { echo 'Refusing invalid CI run ID' >&2; exit 2; } +project="prompt-ci-$GITEA_RUN_ID" +filter="label=com.docker.compose.project=$project" +# Recheck ownership immediately before removal; never target a shared stack. +containers=$(docker ps -aq --filter "$filter") +for resource in $containers; do + [[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$resource")" == "$project" ]] || { echo "Refusing foreign resource: $resource" >&2; exit 2; } + docker rm -f "$resource" +done +volumes=$(docker volume ls -q --filter "$filter") +for resource in $volumes; do + [[ "$(docker volume inspect --format '{{index .Labels "com.docker.compose.project"}}' "$resource")" == "$project" ]] || { echo "Refusing foreign resource: $resource" >&2; exit 2; } + docker volume rm "$resource" +done +networks=$(docker network ls -q --filter "$filter") +for resource in $networks; do + [[ "$(docker network inspect --format '{{index .Labels "com.docker.compose.project"}}' "$resource")" == "$project" ]] || { echo "Refusing foreign resource: $resource" >&2; exit 2; } + docker network rm "$resource" +done diff --git a/scripts/integration.sh b/scripts/integration.sh index d08dad7..feab31e 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -2,7 +2,8 @@ set -Eeuo pipefail : "${WATERFALL_WIKI_IMAGE:?Build the runtime image first}" : "${WATERFALL_WIKI_TEST_IMAGE:?Build the integration image first}" -export COMPOSE_PROJECT_NAME="prompt-ci-${GITEA_RUN_ID:-$$}" +export GITEA_RUN_ID="${GITEA_RUN_ID:-$$}" +export COMPOSE_PROJECT_NAME="prompt-ci-$GITEA_RUN_ID" export PG_PORT=0 S3_PORT=0 WEB_PORT=0 WEB_BIND_ADDRESS=127.0.0.1 TEST_ENV=$(mktemp) chmod 0600 "$TEST_ENV" @@ -13,8 +14,19 @@ if [[ -n "${STUDIO_REVIEW_IMAGE:-}" ]]; then STUDIO_SERVICES+=(prompt-studio) fi compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${COMPOSE_FILES[@]}" "$@"; } -cleanup(){ code=$?; if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}"; fi; compose down -v --remove-orphans; rm -f "$TEST_ENV"; } +cleanup(){ + code=$? + trap - EXIT INT TERM + if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true; fi + cleanup_code=0 + bash scripts/cleanup-integration.sh || cleanup_code=$? + rm -f "$TEST_ENV" + if [ "$code" -ne 0 ]; then exit "$code"; fi + exit "$cleanup_code" +} trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM # Random credentials exist only for this disposable stack; no host Node required. docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV" # Explicitly isolate legacy unauthenticated integration coverage; production fails closed. diff --git a/scripts/test-integration-cleanup.py b/scripts/test-integration-cleanup.py new file mode 100644 index 0000000..4964431 --- /dev/null +++ b/scripts/test-integration-cleanup.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Exercise cleanup ownership checks without a Docker daemon or build.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name('cleanup-integration.sh') +FAKE_DOCKER = '''#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +with open(os.environ['DOCKER_CALLS'], 'a') as output: + output.write(json.dumps(args) + '\\n') +if os.environ.get('EMPTY_INVENTORY'): + sys.exit(0) +if args[0] == 'ps': + if os.environ.get('FAIL_LIST'): + sys.exit(1) + print('test-container') +elif args[:2] == ['volume', 'ls']: + print('test-volume') +elif args[:2] == ['network', 'ls']: + print('test-network') +elif 'inspect' in args: + print(os.environ.get('RESOURCE_OWNER', 'prompt-ci-605')) +''' + + +class CleanupTests(unittest.TestCase): + def run_cleanup(self, run_id='605', **extra): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + docker = root / 'docker' + docker.write_text(FAKE_DOCKER) + docker.chmod(0o700) + calls = root / 'calls' + env = {**os.environ, 'PATH': directory + os.pathsep + os.environ['PATH'], + 'GITEA_RUN_ID': run_id, 'DOCKER_CALLS': str(calls), **extra} + result = subprocess.run(['bash', str(SCRIPT)], env=env, capture_output=True) + return result.returncode, [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else [] + + def test_removes_only_run_owned_resources_in_dependency_order(self): + code, calls = self.run_cleanup() + self.assertEqual(code, 0) + self.assertEqual([args for args in calls if 'rm' in args], [ + ['rm', '-f', 'test-container'], ['volume', 'rm', 'test-volume'], + ['network', 'rm', 'test-network']]) + for args in calls: + if '--filter' in args: + self.assertEqual(args[-1], 'label=com.docker.compose.project=prompt-ci-605') + self.assertEqual(sum('inspect' in args for args in calls), 3) + + def test_already_clean_stack_is_a_successful_noop(self): + code, calls = self.run_cleanup(EMPTY_INVENTORY='1') + self.assertEqual(code, 0) + self.assertEqual(len(calls), 3) + self.assertFalse(any('rm' in args for args in calls)) + + def test_invalid_or_production_project_never_contacts_docker(self): + for value in ['', 'prompt', '0', '-1', '605*', '605/../prompt']: + with self.subTest(value=value): + code, calls = self.run_cleanup(value) + self.assertNotEqual(code, 0) + self.assertEqual(calls, []) + + def test_foreign_label_prevents_removal(self): + code, calls = self.run_cleanup(RESOURCE_OWNER='prompt') + self.assertNotEqual(code, 0) + self.assertFalse(any('rm' in args for args in calls)) + + def test_inventory_failure_does_not_continue_with_deletion(self): + code, calls = self.run_cleanup(FAIL_LIST='1') + self.assertNotEqual(code, 0) + self.assertEqual(len(calls), 1) + + +if __name__ == '__main__': + unittest.main()