diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ecd75fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,256 @@ +name: CI/CD Pipeline with Secure Ephemeral PiP + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + REGISTRY: ${{ secrets.REGISTRY_HOST }} + APP_NAME: ${{ secrets.APP_NAME }} + IMAGE_TAG: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + # Required pinned digests (fail if missing) + RUST_IMG_DIGEST: ${{ secrets.RUST_IMG_DIGEST }} # e.g., docker.io/library/rust@sha256:... + NODE_IMG_DIGEST: ${{ secrets.NODE_IMG_DIGEST }} # e.g., docker.io/library/node@sha256:... + POSTGRES_IMG_DIGEST: ${{ secrets.POSTGRES_IMG_DIGEST }} # e.g., docker.io/library/postgres@sha256:... + PODMAN_CLIENT_IMG_DIGEST: ${{ secrets.PODMAN_CLIENT_IMG_DIGEST }} # e.g., quay.io/podman/stable@sha256:... + SELINUX_ZLABEL: "" # set to ":z" if SELinux is enforcing + +jobs: + test-backend: + runs-on: [self-hosted, ci] + steps: + - uses: actions/checkout@v4 + + - name: Verify pinned digests provided + run: | + for v in RUST_IMG_DIGEST NODE_IMG_DIGEST POSTGRES_IMG_DIGEST PODMAN_CLIENT_IMG_DIGEST; do + [ -n "${!v}" ] || { echo "Missing $v"; exit 1; } + echo "${!v}" | grep -Eq '^.+@sha256:[0-9a-f]{64}' || { echo "$v must be a digest ref"; exit 1; } + done + + - name: Setup ephemeral PiP container + env: + PODMAN_CLIENT_IMG_DIGEST: ${{ env.PODMAN_CLIENT_IMG_DIGEST }} + run: | + chmod +x ./secure_pip_setup.sh + ./secure_pip_setup.sh + + - name: Wait for PiP readiness + env: + PIP_NAME: ci-pip-${{ env.RUN_ID }} + run: | + chmod +x ./pip_ready.sh + ./pip_ready.sh + + - name: Setup SSH with pinned known_hosts (per-step) + env: + GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts' + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + install -m 600 /dev/null ~/.ssh/id_ed25519 + printf '%s' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + install -m 644 /dev/null ~/.ssh/known_hosts + printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + # git commands in this step will use $GIT_SSH_COMMAND + + - name: Create internal integration network + run: podman exec ci-pip-${{ env.RUN_ID }} podman network create --internal integ-${{ env.RUN_ID }} + + - name: Start PostgreSQL on internal network (digest-pinned) + run: | + podman exec ci-pip-${{ env.RUN_ID }} podman run -d \ + --name test-postgres \ + --network integ-${{ env.RUN_ID }} \ + -e POSTGRES_PASSWORD=testpassword \ + -e POSTGRES_USER=testuser \ + -e POSTGRES_DB=testdb \ + "${POSTGRES_IMG_DIGEST}" + + - name: Wait for PostgreSQL to be ready + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc ' + i=0; until podman exec test-postgres pg_isready -h 127.0.0.1 -p 5432 -U testuser >/dev/null 2>&1; do + i=$((i+1)); [ $i -gt 60 ] && { echo "pg not ready"; exit 1; }; sleep 1 + done' + + - name: Run backend unit tests + env: + WORKSPACE: ${{ github.workspace }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc ' + podman run --rm \ + -v "$WORKSPACE":/workspace${SELINUX_ZLABEL} \ + -w /workspace \ + "'"${RUST_IMG_DIGEST}"'" \ + sh -c "cargo test --lib -- --test-threads=1"' + + - name: Run backend integration tests + env: + WORKSPACE: ${{ github.workspace }} + RUN_ID: ${{ env.RUN_ID }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc ' + podman run --rm \ + --network integ-'"$RUN_ID"' \ + -v "$WORKSPACE":/workspace'"${SELINUX_ZLABEL}"' \ + -w /workspace \ + -e DATABASE_URL=postgres://testuser:testpassword@test-postgres:5432/testdb \ + "'"${RUST_IMG_DIGEST}"'" \ + sh -c "cargo test --test '"'"'*'"'"' -- --test-threads=1"' + + - name: Cleanup test resources + if: always() + run: | + podman exec ci-pip-${{ env.RUN_ID }} podman rm -f test-postgres 2>/dev/null || true + podman exec ci-pip-${{ env.RUN_ID }} podman network rm integ-${{ env.RUN_ID }} 2>/dev/null || true + + - name: Per-job cleanup (PiP container) + if: always() + run: podman rm -f ci-pip-${{ env.RUN_ID }} 2>/dev/null || true + + test-frontend: + runs-on: [self-hosted, ci] + needs: test-backend + steps: + - uses: actions/checkout@v4 + + - name: Verify pinned digests provided + run: | + for v in NODE_IMG_DIGEST PODMAN_CLIENT_IMG_DIGEST; do + [ -n "${!v}" ] || { echo "Missing $v"; exit 1; } + echo "${!v}" | grep -Eq '^.+@sha256:[0-9a-f]{64}' || { echo "$v must be a digest ref"; exit 1; } + done + + - name: Setup ephemeral PiP container + env: + PODMAN_CLIENT_IMG_DIGEST: ${{ env.PODMAN_CLIENT_IMG_DIGEST }} + run: | + chmod +x ./secure_pip_setup.sh + ./secure_pip_setup.sh + + - name: Wait for PiP readiness + env: + PIP_NAME: ci-pip-${{ env.RUN_ID }} + run: | + chmod +x ./pip_ready.sh + ./pip_ready.sh + + - name: Run frontend tests (digest-pinned) + env: + WORKSPACE: ${{ github.workspace }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc ' + podman run --rm \ + -v "$WORKSPACE":/workspace'"${SELINUX_ZLABEL}"' \ + -w /workspace \ + "'"${NODE_IMG_DIGEST}"'" \ + sh -c "npm ci && npm run test"' + + - name: Per-job cleanup (PiP container) + if: always() + run: podman rm -f ci-pip-${{ env.RUN_ID }} 2>/dev/null || true + + build-backend: + runs-on: [self-hosted, ci] + needs: test-frontend + steps: + - uses: actions/checkout@v4 + + - name: Setup ephemeral PiP container + env: + PODMAN_CLIENT_IMG_DIGEST: ${{ env.PODMAN_CLIENT_IMG_DIGEST }} + run: | + chmod +x ./secure_pip_setup.sh + ./secure_pip_setup.sh + + - name: Wait for PiP readiness + env: + PIP_NAME: ci-pip-${{ env.RUN_ID }} + run: | + chmod +x ./pip_ready.sh + ./pip_ready.sh + + - name: Login to Forgejo Container Registry securely + env: + REGISTRY_HOST: ${{ env.REGISTRY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + podman exec -i ci-pip-${{ env.RUN_ID }} sh -lc \ + 'podman login "$REGISTRY_HOST" -u "$REGISTRY_USERNAME" --password-stdin' <<<"${REGISTRY_TOKEN}" + + - name: Build backend image + env: + REGISTRY: ${{ env.REGISTRY }} + APP_NAME: ${{ env.APP_NAME }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc \ + 'cd /workspace/backend && podman build -t "$REGISTRY/$APP_NAME/backend:$IMAGE_TAG" .' + + - name: Push backend image + env: + REGISTRY: ${{ env.REGISTRY }} + APP_NAME: ${{ env.APP_NAME }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc \ + 'podman push "$REGISTRY/$APP_NAME/backend:$IMAGE_TAG"' + + - name: Per-job cleanup (PiP container) + if: always() + run: podman rm -f ci-pip-${{ env.RUN_ID }} 2>/dev/null || true + + build-frontend: + runs-on: [self-hosted, ci] + needs: test-frontend + steps: + - uses: actions/checkout@v4 + + - name: Setup ephemeral PiP container + env: + PODMAN_CLIENT_IMG_DIGEST: ${{ env.PODMAN_CLIENT_IMG_DIGEST }} + run: | + chmod +x ./secure_pip_setup.sh + ./secure_pip_setup.sh + + - name: Wait for PiP readiness + env: + PIP_NAME: ci-pip-${{ env.RUN_ID }} + run: | + chmod +x ./pip_ready.sh + ./pip_ready.sh + + - name: Login to Forgejo Container Registry securely + env: + REGISTRY_HOST: ${{ env.REGISTRY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + podman exec -i ci-pip-${{ env.RUN_ID }} sh -lc \ + 'podman login "$REGISTRY_HOST" -u "$REGISTRY_USERNAME" --password-stdin' <<<"${REGISTRY_TOKEN}" + + - name: Build frontend image + env: + REGISTRY: ${{ env.REGISTRY }} + APP_NAME: ${{ env.APP_NAME }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc \ + 'cd /workspace/frontend && podman build -t "$REGISTRY/$APP_NAME/frontend:$IMAGE_TAG" .' + + - name: Push frontend image + env: + REGISTRY: ${{ env.REGISTRY }} + APP_NAME: ${{ env.APP_NAME }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + podman exec ci-pip-${{ env.RUN_ID }} sh -lc \ + 'podman push "$REGISTRY/$APP_NAME/frontend:$IMAGE_TAG"' + + - name: Per-job cleanup (PiP container) + if: always() + run: podman rm -f ci-pip-${{ env.RUN_ID }} 2>/dev/null || true \ No newline at end of file diff --git a/pip_ready.sh b/pip_ready.sh old mode 100644 new mode 100755 index 56cd677..611cf25 --- a/pip_ready.sh +++ b/pip_ready.sh @@ -1,59 +1,30 @@ -#!/bin/bash -set -euo pipefail +#!/usr/bin/env bash +set -Eeuo pipefail -# pip_ready.sh - Socket-only readiness probe for PiP container -# Checks if PiP container can connect to host Podman via UNIX socket +RUN_ID="${RUN_ID:-${GITHUB_RUN_ID:-local}}" +PIP_NAME="${PIP_NAME:-ci-pip-${RUN_ID}}" +TIMEOUT="${TIMEOUT:-30}" +SLEEP="${SLEEP:-2}" -RUN_ID="${GITHUB_RUN_ID:-local}" -PIP_CONTAINER_NAME="ci-pip-${RUN_ID}" -MAX_RETRIES=15 -RETRY_DELAY=2 +echo "Probing PiP readiness for ${PIP_NAME} (up to ${TIMEOUT}s)..." -# Function to check PiP socket connectivity only -check_pip_ready() { - echo "๐Ÿ” Checking PiP container socket connectivity..." - - # Check if container exists and is running - if ! podman inspect "${PIP_CONTAINER_NAME}" --format '{{.State.Status}}' 2>/dev/null | grep -q running; then - echo "โŒ PiP container not running" - return 1 - fi - - # Test basic Podman command connectivity (socket only) - if ! podman exec "${PIP_CONTAINER_NAME}" podman info --format json >/dev/null 2>&1; then - echo "โš ๏ธ PiP container running but socket connectivity failed" - return 1 - fi - - # Test version command (socket connectivity verification) - if ! podman exec "${PIP_CONTAINER_NAME}" podman version >/dev/null 2>&1; then - echo "โš ๏ธ PiP container socket connection unstable" - return 1 - fi - - echo "โœ… PiP container ready with socket connectivity" - return 0 -} +if ! podman inspect "$PIP_NAME" >/dev/null 2>&1; then + echo "ERROR: ${PIP_NAME} not found." >&2 + exit 1 +fi -# Main readiness check with retries -attempt=1 -while [[ ${attempt} -le ${MAX_RETRIES} ]]; do - if check_pip_ready; then - echo "๐ŸŽ‰ PiP container is ready for CI operations!" - exit 0 - fi - - echo "โณ PiP socket connectivity not ready yet (attempt ${attempt}/${MAX_RETRIES}), retrying in ${RETRY_DELAY}s..." - sleep ${RETRY_DELAY} - attempt=$((attempt + 1)) +end=$((SECONDS + TIMEOUT)) +while (( SECONDS < end )); do + if podman exec "$PIP_NAME" podman version >/dev/null 2>&1 \ + && podman exec "$PIP_NAME" podman info >/dev/null 2>&1; then + echo "PiP readiness OK." + exit 0 + fi + sleep "$SLEEP" done -# If we reach here, all retries failed -echo "โŒ ERROR: PiP container failed to establish socket connectivity after ${MAX_RETRIES} attempts" -echo "๐Ÿ“‹ Container status:" -podman ps -a --filter "name=${PIP_CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true - -echo "๐Ÿ“‹ Container logs:" -podman logs "${PIP_CONTAINER_NAME}" 2>/dev/null || echo "No logs available" - +echo "ERROR: PiP not ready. Diagnostics:" >&2 +podman ps >&2 || true +podman logs "$PIP_NAME" >&2 || true +podman exec "$PIP_NAME" podman info >&2 || true exit 1 \ No newline at end of file diff --git a/podman-host-socket.service b/podman-host-socket.service index c21835f..e3e1dfc 100644 --- a/podman-host-socket.service +++ b/podman-host-socket.service @@ -5,6 +5,8 @@ After=default.target [Service] Type=simple Environment="XDG_RUNTIME_DIR=/run/user/%U" +ExecStartPre=/usr/bin/mkdir -p ${XDG_RUNTIME_DIR}/podman-host +ExecStartPre=/usr/bin/chmod 770 ${XDG_RUNTIME_DIR}/podman-host ExecStart=/usr/bin/podman system service --time=0 unix://${XDG_RUNTIME_DIR}/podman-host/podman.sock Restart=always RestartSec=2 diff --git a/secure_pip_setup.sh b/secure_pip_setup.sh old mode 100644 new mode 100755 index 7c4a199..b572e6c --- a/secure_pip_setup.sh +++ b/secure_pip_setup.sh @@ -1,63 +1,57 @@ -#!/bin/bash -set -euo pipefail +#!/usr/bin/env bash +set -Eeuo pipefail -# secure_pip_setup.sh - Secure PiP client container setup -# Creates ephemeral PiP container that connects to host Podman via UNIX socket - -# Configuration -RUN_ID="${GITHUB_RUN_ID:-local}" +RUN_ID="${RUN_ID:-${GITHUB_RUN_ID:-local}}" PIP_CONTAINER_NAME="ci-pip-${RUN_ID}" -SOCKET_PATH="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman-host/podman.sock" -PODMAN_IMAGE="quay.io/podman/stable@sha256:abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890" +RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +SOCKET_PATH="${SOCKET_PATH:-${RUNTIME_DIR}/podman-host/podman.sock}" WORKSPACE="${GITHUB_WORKSPACE:-$PWD}" +PIP_UID="${PIP_UID:-1000}" +PIP_GID="${PIP_GID:-1000}" -# Clean up any existing container for this run -echo "๐Ÿงน Cleaning up any existing PiP container for run ${RUN_ID}..." -podman rm -f "${PIP_CONTAINER_NAME}" 2>/dev/null || true - -# Verify the systemd-managed socket exists -echo "๐Ÿ” Checking for systemd-managed Podman socket..." -if [[ ! -S "${SOCKET_PATH}" ]]; then - echo "โŒ ERROR: Podman socket not found at ${SOCKET_PATH}" - echo " Ensure the podman-host-socket.service is running:" - echo " sudo -u ci-service systemctl --user enable --now podman-host-socket.service" - exit 1 +# Require pinned client image digest +PODMAN_CLIENT_IMG_DIGEST="${PODMAN_CLIENT_IMG_DIGEST:-}" +if [[ -z "${PODMAN_CLIENT_IMG_DIGEST}" ]]; then + echo "ERROR: PODMAN_CLIENT_IMG_DIGEST (e.g., quay.io/podman/stable@sha256:...) is required and must be a digest." >&2 + exit 1 fi -# Set secure permissions on socket (in case they were reset) -echo "๐Ÿ”’ Setting secure socket permissions..." -chmod 660 "${SOCKET_PATH}" 2>/dev/null || true +# Clean any previous container for this run +podman rm -f "${PIP_CONTAINER_NAME}" >/dev/null 2>&1 || true -# Create ephemeral PiP container as client only (no inner daemon) -echo "๐Ÿณ Creating secure PiP client container with workspace mount..." +# Verify systemd-managed UNIX socket exists +if [[ ! -S "${SOCKET_PATH}" ]]; then + echo "ERROR: Podman UNIX socket not found at ${SOCKET_PATH}." >&2 + echo "Start it with: systemctl --user enable --now podman-host-socket.service" >&2 + exit 1 +fi + +# Tighten socket perms (best-effort) +chmod 660 "${SOCKET_PATH}" >/dev/null 2>&1 || true + +# Create ephemeral PiP client (no network, least privilege) podman run -d \ --name "${PIP_CONTAINER_NAME}" \ + --user ${PIP_UID}:${PIP_GID} \ + -e HOME=/tmp \ --security-opt=no-new-privileges \ --cap-drop=ALL \ --read-only \ --network=none \ --tmpfs /run:rw,size=64M \ --tmpfs /tmp:rw,size=256M \ - -v "${SOCKET_PATH}:/var/run/podman.sock" \ - -v "${WORKSPACE}:/workspace:rw" \ + -v "${SOCKET_PATH}:/var/run/podman.sock${SELINUX_ZLABEL:-}" \ + -v "${WORKSPACE}:/workspace:rw${SELINUX_ZLABEL:-}" \ -e CONTAINER_HOST="unix:///var/run/podman.sock" \ - "${PODMAN_IMAGE}" \ + "${PODMAN_CLIENT_IMG_DIGEST}" \ sleep infinity -# Wait for container to start -echo "โณ Waiting for PiP container to start..." +# Brief wait and health check sleep 3 - -# Verify container is running if ! podman inspect "${PIP_CONTAINER_NAME}" --format '{{.State.Status}}' | grep -q running; then - echo "โŒ ERROR: PiP container failed to start" - podman logs "${PIP_CONTAINER_NAME}" || true - kill ${HOST_PODMAN_PID} 2>/dev/null || true - exit 1 + echo "ERROR: PiP container failed to start" >&2 + podman logs "${PIP_CONTAINER_NAME}" >&2 || true + exit 1 fi -echo "๐ŸŽ‰ Secure PiP client container setup complete!" -echo " Container: ${PIP_CONTAINER_NAME}" -echo " Socket: ${SOCKET_PATH}" -echo " Workspace: ${WORKSPACE} โ†’ /workspace" -echo " Security: No network, no capabilities, read-only rootfs, client-only" \ No newline at end of file +echo "PiP container ready: ${PIP_CONTAINER_NAME}" \ No newline at end of file