From ffaad2cd6c076a1f46c22124ddbf3e0b029d247b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Wed, 17 Jun 2026 16:32:10 +0200 Subject: [PATCH 1/4] Add per-PR frontend preview environments Deploy an ephemeral preview of the web-client for PRs touching the frontend. On open/sync, the PR's web-client is built, pushed to GHCR, and deployed into a preview-pr- namespace; the production backend is reused via a spring-api ExternalName alias, so it's one ~50m/64Mi pod against real data. A sticky comment posts the per-PR URL (wildcard DNS + per-host Let's Encrypt). The namespace is torn down when the PR is closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/deploy-k8s-client-preview.yml | 96 +++++++++++++++++++ infra/k8s/preview/deploy.sh | 57 +++++++++++ infra/k8s/preview/manifests.yaml | 65 +++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 .github/workflows/deploy-k8s-client-preview.yml create mode 100755 infra/k8s/preview/deploy.sh create mode 100644 infra/k8s/preview/manifests.yaml diff --git a/.github/workflows/deploy-k8s-client-preview.yml b/.github/workflows/deploy-k8s-client-preview.yml new file mode 100644 index 0000000..54ef4e0 --- /dev/null +++ b/.github/workflows/deploy-k8s-client-preview.yml @@ -0,0 +1,96 @@ +name: PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + paths: + - 'web-client/**' + - 'infra/k8s/preview/**' + - '.github/workflows/deploy-k8s-client-preview.yml' + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + pull-requests: write + +env: + # gets prefixed with the PR number + PREVIEW_DOMAIN: devsecops.stud.k8s.aet.cit.tum.de + +jobs: + deploy: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + environment: kubernetes + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Image prefix + run: echo "IMAGE_PREFIX=ghcr.io/$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build web-client + uses: docker/build-push-action@v6 + with: + context: web-client + push: true + tags: ${{ env.IMAGE_PREFIX }}/web-client:preview-pr-${{ github.event.pull_request.number }} + + - name: Set up kubectl + uses: azure/setup-kubectl@v4 + + - name: Configure kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Deploy preview + id: deploy + env: + WEB_CLIENT_IMAGE: ${{ env.IMAGE_PREFIX }}/web-client:preview-pr-${{ github.event.pull_request.number }} + run: | + url=$(infra/k8s/preview/deploy.sh "${{ github.event.pull_request.number }}") + echo "url=$url" >> "$GITHUB_OUTPUT" + + - name: Comment preview URL + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ github.event.pull_request.number }} + header: preview + message: ๐Ÿš€ [Frontend preview โ†—](${{ steps.deploy.outputs.url }})  ยท  commit `${{ github.event.pull_request.head.sha }}` + + teardown: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + environment: kubernetes + steps: + - uses: actions/checkout@v4 + - name: Set up kubectl + uses: azure/setup-kubectl@v4 + - name: Configure kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + - name: Tear down preview + run: kubectl delete namespace "preview-pr-${{ github.event.pull_request.number }}" --ignore-not-found --wait=false + - name: Update comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ github.event.pull_request.number }} + header: preview + message: ๐Ÿงน The preview environment was removed because this PR was closed. diff --git a/infra/k8s/preview/deploy.sh b/infra/k8s/preview/deploy.sh new file mode 100755 index 0000000..c9351dc --- /dev/null +++ b/infra/k8s/preview/deploy.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Deploy (or update) a per-PR frontend preview environment. +# Usage: deploy.sh +# Env: WEB_CLIENT_IMAGE (required) โ€” web-client image ref to deploy +# PREVIEW_DOMAIN (default devsecops.stud.k8s.aet.cit.tum.de) +set -euo pipefail + +PR="${1:?pr number required}" +NS="preview-pr-${PR}" +HOST="pr-${PR}.${PREVIEW_DOMAIN:-devsecops.stud.k8s.aet.cit.tum.de}" +export WEB_CLIENT_IMAGE="${WEB_CLIENT_IMAGE:?}" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# create a new namespace +kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - +kubectl label namespace "$NS" \ + app.kubernetes.io/part-of=pr-preview \ + preview-pr="$PR" --overwrite + +# deploy the manifests.yaml to our created namespace +envsubst '${WEB_CLIENT_IMAGE}' < "$DIR/manifests.yaml" | kubectl apply -n "$NS" -f - + +# setup TLS and expose the deployment using an Ingress +# (this is done inline to easily substitute $HOST) +kubectl apply -n "$NS" -f - < Date: Wed, 17 Jun 2026 16:37:40 +0200 Subject: [PATCH 2/4] Deploy previews into the existing app namespace The AET cluster token is namespace-scoped and cannot create namespaces, so the per-PR-namespace approach failed with a Forbidden error. Deploy preview resources into the existing `app` namespace instead, named/labelled per PR (web-client-pr-, preview-pr=). The production spring-api Service is already in this namespace, so the web-client's nginx proxy reaches it directly and the ExternalName alias is dropped. Teardown deletes by the preview-pr label. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/deploy-k8s-client-preview.yml | 5 ++- infra/k8s/preview/deploy.sh | 39 +++++++++---------- infra/k8s/preview/manifests.yaml | 36 ++++++++--------- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/.github/workflows/deploy-k8s-client-preview.yml b/.github/workflows/deploy-k8s-client-preview.yml index 54ef4e0..22baba0 100644 --- a/.github/workflows/deploy-k8s-client-preview.yml +++ b/.github/workflows/deploy-k8s-client-preview.yml @@ -87,7 +87,10 @@ jobs: echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config chmod 600 ~/.kube/config - name: Tear down preview - run: kubectl delete namespace "preview-pr-${{ github.event.pull_request.number }}" --ignore-not-found --wait=false + run: | + PR="${{ github.event.pull_request.number }}" + kubectl delete deployment,service,ingress -n app -l "preview-pr=$PR" + kubectl delete secret "preview-pr-$PR-tls" -n app --ignore-not-found - name: Update comment uses: marocchino/sticky-pull-request-comment@v2 with: diff --git a/infra/k8s/preview/deploy.sh b/infra/k8s/preview/deploy.sh index c9351dc..e6237a8 100755 --- a/infra/k8s/preview/deploy.sh +++ b/infra/k8s/preview/deploy.sh @@ -1,32 +1,30 @@ #!/usr/bin/env bash -# Deploy (or update) a per-PR frontend preview environment. +# Deploy (or update) a per-PR frontend preview into the team's existing namespace. # Usage: deploy.sh -# Env: WEB_CLIENT_IMAGE (required) โ€” web-client image ref to deploy -# PREVIEW_DOMAIN (default devsecops.stud.k8s.aet.cit.tum.de) +# Env: WEB_CLIENT_IMAGE (required) โ€” web-client image ref to deploy +# PREVIEW_DOMAIN (default devsecops.stud.k8s.aet.cit.tum.de) +# PREVIEW_NAMESPACE (default app) set -euo pipefail PR="${1:?pr number required}" -NS="preview-pr-${PR}" +export PR +NS="${PREVIEW_NAMESPACE:-app}" HOST="pr-${PR}.${PREVIEW_DOMAIN:-devsecops.stud.k8s.aet.cit.tum.de}" export WEB_CLIENT_IMAGE="${WEB_CLIENT_IMAGE:?}" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# create a new namespace -kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - -kubectl label namespace "$NS" \ - app.kubernetes.io/part-of=pr-preview \ - preview-pr="$PR" --overwrite +# web-client Deployment + Service, named/labelled per PR. +envsubst '${PR} ${WEB_CLIENT_IMAGE}' < "$DIR/manifests.yaml" | kubectl apply -n "$NS" -f - -# deploy the manifests.yaml to our created namespace -envsubst '${WEB_CLIENT_IMAGE}' < "$DIR/manifests.yaml" | kubectl apply -n "$NS" -f - - -# setup TLS and expose the deployment using an Ingress -# (this is done inline to easily substitute $HOST) +# Per-PR ingress (inline to substitute the host). Its own TLS secret avoids +# collisions with production and other previews in this shared namespace. kubectl apply -n "$NS" -f - < Date: Wed, 17 Jun 2026 16:40:34 +0200 Subject: [PATCH 3/4] Emit only the preview URL on deploy.sh stdout The deploy step captures the script's stdout into a step output; kubectl's "... created" lines made it multi-line and broke the key=value GITHUB_OUTPUT format. Route command output to stderr (still shown in logs) and write only the URL to fd 3 / stdout. Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/k8s/preview/deploy.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infra/k8s/preview/deploy.sh b/infra/k8s/preview/deploy.sh index e6237a8..a2d7ed3 100755 --- a/infra/k8s/preview/deploy.sh +++ b/infra/k8s/preview/deploy.sh @@ -13,6 +13,10 @@ HOST="pr-${PR}.${PREVIEW_DOMAIN:-devsecops.stud.k8s.aet.cit.tum.de}" export WEB_CLIENT_IMAGE="${WEB_CLIENT_IMAGE:?}" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Route command output to stderr (still shown in logs) so stdout carries only +# the final URL line, which the caller captures. fd 3 is the real stdout. +exec 3>&1 1>&2 + # web-client Deployment + Service, named/labelled per PR. envsubst '${PR} ${WEB_CLIENT_IMAGE}' < "$DIR/manifests.yaml" | kubectl apply -n "$NS" -f - @@ -51,4 +55,4 @@ EOF kubectl rollout restart "deployment/web-client-pr-${PR}" -n "$NS" kubectl rollout status "deployment/web-client-pr-${PR}" -n "$NS" --timeout=180s -echo "https://${HOST}/team-devsecops/" +echo "https://${HOST}/team-devsecops/" >&3 From d17df32deae0032a1e256fbb125443582fad062f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Wed, 17 Jun 2026 17:00:45 +0200 Subject: [PATCH 4/4] improve comments --- infra/k8s/preview/deploy.sh | 13 ++++++------- infra/k8s/preview/manifests.yaml | 10 +++------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/infra/k8s/preview/deploy.sh b/infra/k8s/preview/deploy.sh index a2d7ed3..8cbafc1 100755 --- a/infra/k8s/preview/deploy.sh +++ b/infra/k8s/preview/deploy.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Deploy (or update) a per-PR frontend preview into the team's existing namespace. +# Deploy (or update) a per-PR frontend preview into the existing namespace. # Usage: deploy.sh # Env: WEB_CLIENT_IMAGE (required) โ€” web-client image ref to deploy # PREVIEW_DOMAIN (default devsecops.stud.k8s.aet.cit.tum.de) @@ -13,15 +13,14 @@ HOST="pr-${PR}.${PREVIEW_DOMAIN:-devsecops.stud.k8s.aet.cit.tum.de}" export WEB_CLIENT_IMAGE="${WEB_CLIENT_IMAGE:?}" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Route command output to stderr (still shown in logs) so stdout carries only -# the final URL line, which the caller captures. fd 3 is the real stdout. +# Route command output to stderr so stdout carries only the final URL line exec 3>&1 1>&2 -# web-client Deployment + Service, named/labelled per PR. +# web-client Deployment + Service, named/labelled per PR envsubst '${PR} ${WEB_CLIENT_IMAGE}' < "$DIR/manifests.yaml" | kubectl apply -n "$NS" -f - -# Per-PR ingress (inline to substitute the host). Its own TLS secret avoids -# collisions with production and other previews in this shared namespace. +# setup TLS and expose the deployment using an Ingress +# (this is done inline to easily substitute $HOST) kubectl apply -n "$NS" -f - <