Clean disposable CI stacks on interruption and job completion
creative workspace review checks / verify (pull_request) Failing after 7m18s
creative workspace review checks / verify (pull_request) Failing after 7m18s
This commit is contained in:
@@ -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
|
||||
+14
-2
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user