diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4d7e80 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +dist_keycloak +build +storybook-static +.git +.github +.storybook +.vscode +.DS_Store +*.log diff --git a/.github/workflows/deploy-keycloak-staging.yml b/.github/workflows/deploy-keycloak-staging.yml new file mode 100644 index 0000000..7a3a6ce --- /dev/null +++ b/.github/workflows/deploy-keycloak-staging.yml @@ -0,0 +1,200 @@ +name: Deploy Keycloak theme to staging + +# `dev` is the integration branch for staging: anything merged there is what +# staging should be running. `main` is deliberately not wired up -- it does not +# yet carry the theme work, so auto-deploying it would regress staging. +on: + push: + branches: + - dev + workflow_dispatch: + inputs: + ref: + description: "Branch, tag or SHA to build and deploy (defaults to dev)" + type: string + required: false + default: dev + +concurrency: + group: keycloak-staging-deploy + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + # Lowercased: github.repository is mixed-case and Docker rejects + # uppercase in image names. + IMAGE_NAME: civicdatalab/dataspacekeycloaktheme + DEPLOY_PATH: keycloak + COMPOSE_SERVICE: keycloak + CONTAINER_NAME: keycloak-staging + +jobs: + build: + name: Build & push image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + outputs: + image_ref: ${{ steps.push.outputs.image_ref }} + theme_jar_sha256: ${{ steps.jar.outputs.sha256 }} + steps: + # On a push this is the pushed commit; on a manual run it is whatever + # ref was asked for. + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Resolve commit SHA + id: commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build the theme stage on its own first so the jar's sha256 can be read + # out and baked into the final image as a label. This is the whole point: + # "which theme is this box running" becomes one `docker inspect`. + - name: Build theme stage and extract jar sha256 + id: jar + run: | + set -euo pipefail + docker buildx build \ + --platform linux/amd64 \ + --target theme-build \ + --cache-from type=gha \ + --cache-to type=gha,mode=max \ + --load -t theme-build:ci . + docker create --name themejar theme-build:ci > /dev/null + docker cp themejar:/app/dist_keycloak ./dist_keycloak + docker rm themejar > /dev/null + + JAR=dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar + test -f "$JAR" + SHA=$(sha256sum "$JAR" | cut -d' ' -f1) + SIZE=$(stat -c%s "$JAR") + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + + echo "### Theme jar" >> "$GITHUB_STEP_SUMMARY" + echo "- file: \`$(basename "$JAR")\`" >> "$GITHUB_STEP_SUMMARY" + echo "- sha256: \`$SHA\`" >> "$GITHUB_STEP_SUMMARY" + echo "- size: $SIZE bytes" >> "$GITHUB_STEP_SUMMARY" + ls -l dist_keycloak + + # Production's canonical jar is 2,351,205 bytes. A different build will + # never match byte-for-byte, but an order-of-magnitude difference means + # the build produced something other than this theme. + if [ "$SIZE" -lt 1500000 ] || [ "$SIZE" -gt 4000000 ]; then + echo "::error::theme jar is $SIZE bytes, far outside the expected ~2.3MB. Refusing to deploy." + exit 1 + fi + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=staging + type=raw,value=sha-${{ steps.commit.outputs.sha }} + + - name: Build and push + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT_SHA=${{ steps.commit.outputs.sha }} + THEME_JAR_SHA256=${{ steps.jar.outputs.sha256 }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Pin image by digest + id: push + run: | + echo "image_ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT" + + deploy: + name: Deploy to staging + needs: build + runs-on: ubuntu-latest + environment: keycloak-staging + timeout-minutes: 15 + permissions: + contents: read + packages: read + + steps: + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.0.3 + env: + KEYCLOAK_IMAGE: ${{ needs.build.outputs.image_ref }} + # This job's own GITHUB_TOKEN, not a long-lived PAT. It is valid only + # for the life of this run, so the box holds no standing registry + # credential -- and there is one fewer secret to rotate. + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_ACTOR: ${{ github.actor }} + DEPLOY_PATH: ${{ env.DEPLOY_PATH }} + COMPOSE_SERVICE: ${{ env.COMPOSE_SERVICE }} + CONTAINER_NAME: ${{ env.CONTAINER_NAME }} + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_PRIVATE_KEY }} + envs: KEYCLOAK_IMAGE,GHCR_TOKEN,GHCR_ACTOR,DEPLOY_PATH,COMPOSE_SERVICE,CONTAINER_NAME + command_timeout: 12m + script: | + set -euo pipefail + cd "$HOME/$DEPLOY_PATH" + + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_ACTOR" --password-stdin + trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT + + # Roll back to the exact image that is running now, not to a moving + # tag -- ":staging" will already point at the new build by then. + PREVIOUS_IMAGE=$(docker inspect --format='{{.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "") + echo "Currently running image id: ${PREVIOUS_IMAGE:-}" + + docker compose pull "$COMPOSE_SERVICE" + docker compose up -d --no-deps "$COMPOSE_SERVICE" + + echo "Waiting for Keycloak to become ready..." + for i in $(seq 1 30); do + if curl -sf --max-time 5 http://127.0.0.1:8004/auth/health/ready > /dev/null; then + echo "Ready." + docker inspect --format \ + 'commit={{index .Config.Labels "org.opencontainers.image.revision"}} theme_jar_sha256={{index .Config.Labels "in.civicdatalab.theme.jar.sha256"}}' \ + "$CONTAINER_NAME" + exit 0 + fi + sleep 6 + done + + echo "::error::Keycloak did not become ready after deploy -- rolling back." + if [ -n "$PREVIOUS_IMAGE" ]; then + KEYCLOAK_IMAGE="$PREVIOUS_IMAGE" docker compose up -d --no-deps "$COMPOSE_SERVICE" + for i in $(seq 1 30); do + if curl -sf --max-time 5 http://127.0.0.1:8004/auth/health/ready > /dev/null; then + echo "Rollback healthy." + exit 1 + fi + sleep 6 + done + echo "::error::Rollback image also failed to become ready -- needs manual intervention." + else + echo "::error::No previous image captured -- nothing to roll back to." + fi + exit 1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b2953d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Stage 1 - build the Keycloakify theme jar. +# Keycloakify needs Node, a JDK and Maven >= 3.1.1 all on $PATH. +# --------------------------------------------------------------------------- +FROM node:20-bookworm AS theme-build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openjdk-17-jdk-headless maven \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# --ignore-scripts because the `postinstall` (keycloakify sync-extensions) reads +# src/, which is not copied yet. Deps land in their own cached layer this way, so +# a source-only change does not reinstall them. +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY . . +RUN npx keycloakify sync-extensions +RUN npm run build-keycloak-theme + +# Keycloakify emits one jar per Keycloak version range. Keycloak 24 needs +# `keycloak-theme-for-kc-all-other-versions.jar` specifically -- copying them +# all into providers/ lets Keycloak pick a theme we did not intend. +RUN set -eux; \ + ls -l dist_keycloak; \ + test -f dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar; \ + sha256sum dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar \ + | cut -d' ' -f1 > /tmp/theme-jar-sha256; \ + cat /tmp/theme-jar-sha256 + +# --------------------------------------------------------------------------- +# Stage 2 - bake the theme into the Keycloak image. +# --------------------------------------------------------------------------- +FROM quay.io/keycloak/keycloak:24.0.0 + +COPY --from=theme-build \ + /app/dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar \ + /opt/keycloak/providers/ + +# These four are *build-time* options in Keycloak 24. Setting them only in +# compose `environment:` (as the staging box does today) forces a re-augmentation +# on every start. They must match what compose passes at runtime, or the server +# re-augments and the ~16s cost comes straight back. +ENV KC_DB=postgres \ + KC_HEALTH_ENABLED=true \ + KC_METRICS_ENABLED=true \ + KC_HTTP_RELATIVE_PATH=/auth + +# Providers are registered at build time. Without this the augmentation runs on +# every container start (the ~16s "Quarkus augmentation completed" in the logs) +# and a jar dropped into providers/ on a running server does nothing at all. +RUN /opt/keycloak/bin/kc.sh build + +ARG GIT_COMMIT_SHA=unknown +ARG THEME_JAR_SHA256=unknown + +# So that "which theme is this box running, and from which commit" is answered +# by one `docker inspect` rather than by comparing sha256 sums across servers. +LABEL org.opencontainers.image.revision="${GIT_COMMIT_SHA}" \ + org.opencontainers.image.source="https://github.com/CivicDataLab/DataSpaceKeycloakTheme" \ + in.civicdatalab.theme.jar.sha256="${THEME_JAR_SHA256}"