142 lines
5.3 KiB
Bash
142 lines
5.3 KiB
Bash
#!/usr/bin/env bash
|
|
# No argument preserves the CI build-and-test entry point. Pass an image tag to
|
|
# test an already-built release without building, pulling, or publishing it.
|
|
set -euo pipefail
|
|
|
|
if [ "$#" -gt 1 ]; then
|
|
echo "Usage: $0 [existing-image]" >&2
|
|
exit 2
|
|
fi
|
|
script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
repository_directory="$(cd -- "$script_directory/.." && pwd)"
|
|
image="${1:-magent:ci}"
|
|
size_limit_mb="${MAGENT_IMAGE_MAX_MB:-350}"
|
|
managed_mode="${MAGENT_SMOKE_MANAGED:-false}"
|
|
if [[ "$managed_mode" != true && "$managed_mode" != false ]]; then
|
|
echo "MAGENT_SMOKE_MANAGED must be true or false." >&2
|
|
exit 2
|
|
fi
|
|
if ! [[ "$size_limit_mb" =~ ^[1-9][0-9]*$ ]]; then
|
|
echo "MAGENT_IMAGE_MAX_MB must be a positive integer (MiB)." >&2
|
|
exit 2
|
|
fi
|
|
|
|
container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$-${RANDOM}"
|
|
volume_name="${container_name}-data"
|
|
network_name="${container_name}-isolated"
|
|
container_created=false
|
|
volume_created=false
|
|
network_created=false
|
|
cleanup() {
|
|
result=$?
|
|
trap - EXIT
|
|
if [ "$result" -ne 0 ] && [ "$container_created" = true ]; then
|
|
# Only synthetic credentials/data enter this test container.
|
|
docker logs --tail 100 "$container_name" >&2 || true
|
|
fi
|
|
if [ "$container_created" = true ]; then
|
|
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
|
fi
|
|
if [ "$volume_created" = true ]; then
|
|
docker volume rm "$volume_name" >/dev/null 2>&1 || true
|
|
fi
|
|
if [ "$network_created" = true ]; then
|
|
docker network rm "$network_name" >/dev/null 2>&1 || true
|
|
fi
|
|
exit "$result"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if [ "$#" -eq 0 ]; then
|
|
docker build --tag "$image" "$repository_directory"
|
|
fi
|
|
image_size="$(docker image inspect --format '{{.Size}}' "$image")"
|
|
image_id="$(docker image inspect --format '{{.Id}}' "$image")"
|
|
if [ "$image_size" -gt "$((size_limit_mb * 1024 * 1024))" ]; then
|
|
echo "Image exceeds ${size_limit_mb} MiB unpacked budget: ${image_size} bytes" >&2
|
|
exit 1
|
|
fi
|
|
echo "Image size: ${image_size} bytes (budget ${size_limit_mb} MiB unpacked)"
|
|
|
|
# Inspect the image's original filesystem before tmpfs or volume mounts could
|
|
# hide accidentally shipped build caches or private files.
|
|
docker run --rm --pull never --network none --read-only \
|
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
|
--entrypoint python -i "$image_id" - packaging < "$script_directory/container_smoke.py"
|
|
|
|
# An internal network prevents accidental external integration calls. No host
|
|
# files, existing volumes, host credentials, or host ports are used.
|
|
docker network create --internal "$network_name" >/dev/null
|
|
network_created=true
|
|
docker volume create "$volume_name" >/dev/null
|
|
volume_created=true
|
|
|
|
start_container() {
|
|
local -a secret_environment
|
|
if [ "$managed_mode" = true ]; then
|
|
# Exercise the image defaults: no keys, origin or managed-mode variables.
|
|
secret_environment=()
|
|
else
|
|
secret_environment=(
|
|
--env JWT_SECRET=ci-only-secret-with-at-least-32-characters
|
|
--env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
|
--env SETUP_TOKEN=ci-only-setup-token-with-at-least-32-characters
|
|
--env AUTH_COOKIE_SECURE=true
|
|
--env MAGENT_APPLICATION_URL=https://magent-ci.example.test
|
|
)
|
|
fi
|
|
docker run --detach --name "$container_name" --pull never \
|
|
--network "$network_name" \
|
|
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
|
|
--pids-limit 256 --memory 1g --cpus 2 \
|
|
--tmpfs /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 \
|
|
--tmpfs /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \
|
|
--volume "$volume_name:/app/data" \
|
|
"${secret_environment[@]}" \
|
|
--env ADMIN_PASSWORD= \
|
|
--env AUTH_COOKIE_SAMESITE=strict \
|
|
--env BACKGROUND_TASKS_ENABLED=false \
|
|
--env MAGENT_METRICS_ENABLED=false \
|
|
"$image_id" >/dev/null
|
|
container_created=true
|
|
}
|
|
|
|
wait_for_health() {
|
|
local deadline=$((SECONDS + 150))
|
|
local status
|
|
while true; do
|
|
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_name")"
|
|
if [ "$status" = healthy ]; then
|
|
return
|
|
fi
|
|
if [ "$status" = missing ] || [ "$SECONDS" -ge "$deadline" ]; then
|
|
echo "Container did not become healthy within 150 seconds (status: $status)" >&2
|
|
return 1
|
|
fi
|
|
if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != true ]; then
|
|
echo "Container exited before becoming healthy" >&2
|
|
return 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
# Deliberately no root/chown helper: the image must initialize a fresh named
|
|
# volume with correct ownership for its normal non-root runtime user.
|
|
start_container
|
|
wait_for_health
|
|
docker exec -i "$container_name" python - fresh < "$script_directory/container_smoke.py"
|
|
|
|
docker restart --time 15 "$container_name" >/dev/null
|
|
wait_for_health
|
|
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
|
|
|
|
# Recreation proves database/configuration are in the volume, not merely in the
|
|
# container's writable layer. Both test instances use the same immutable image.
|
|
docker rm -f "$container_name" >/dev/null
|
|
container_created=false
|
|
start_container
|
|
wait_for_health
|
|
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
|
|
echo "Container smoke passed: fresh install, security headers, assets, login, backup/restore, restart, recreation."
|