diff --git a/.github/workflows/k8s-preview-tunnel-smoketest.yaml b/.github/workflows/k8s-preview-tunnel-smoketest.yaml new file mode 100644 index 0000000..be92bb7 --- /dev/null +++ b/.github/workflows/k8s-preview-tunnel-smoketest.yaml @@ -0,0 +1,174 @@ +name: K8s Preview Tunnel Smoketest +# Manual, on-demand check of the Cloudflare Tunnel + DNS + Access plumbing +# that k8s-preview.yaml depends on — without paying for a full k3d + +# platform-stack + helm deploy on every iteration. Serves a static +# index.html instead of JupyterHub; same tunnel/DNS create+configure+ +# delete API calls as the real workflow, same secrets. Run this first +# when validating the Cloudflare-side setup (token scopes, Access +# application, GitHub identity provider); once a visit to the printed +# URL round-trips through GitHub SSO successfully, k8s-preview.yaml's +# tunnel plumbing is known-good and any remaining issue is in the +# k3d/helm/chart side, not Cloudflare. + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to post the smoketest URL to (optional; skips the comment if blank)' + required: false + +env: + PREVIEW_DOMAIN: github.fyi + CLOUDFLARED_VERSION: "2026.7.3" + CLOUDFLARED_SHA256: "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17" + +jobs: + smoketest: + name: Tunnel smoketest + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Serve a trivial static page + run: | + mkdir -p /tmp/preview-test + cat > /tmp/preview-test/index.html <<'EOF' + +

Tunnel smoketest OK

+ EOF + python3 -m http.server 8000 --directory /tmp/preview-test \ + > /tmp/http-server.log 2>&1 & + + - name: Install cloudflared + run: | + curl -fsSL -o /tmp/cloudflared \ + "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64" + echo "${CLOUDFLARED_SHA256} /tmp/cloudflared" | sha256sum -c - + chmod +x /tmp/cloudflared + + - name: Create Cloudflare Tunnel for this run + id: cf_tunnel + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }} + PREVIEW_HOSTNAME: smoketest-${{ github.run_id }}.${{ env.PREVIEW_DOMAIN }} + run: | + tunnel_secret=$(openssl rand -base64 32) + echo "::add-mask::${tunnel_secret}" + + tunnel_name="smoketest-${{ github.run_id }}" + create_resp=$(curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg name "$tunnel_name" --arg secret "$tunnel_secret" \ + '{name: $name, config_src: "cloudflare", tunnel_secret: $secret}')") + tunnel_id=$(jq -r '.result.id // empty' <<< "$create_resp") + + # Same retry-safety as the real workflow: a retry reuses run_id, + # so reuse the existing tunnel by name on a 409 name conflict. + if [ -z "$tunnel_id" ]; then + echo "::warning::Tunnel create failed (likely a name conflict from a retry), looking up existing tunnel named ${tunnel_name}: $create_resp" + tunnel_id=$(curl -fsS "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?name=${tunnel_name}&is_deleted=false" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id // empty') + fi + if [ -z "$tunnel_id" ]; then + echo "::error::Tunnel creation failed and no existing tunnel named ${tunnel_name} found: $create_resp" + exit 1 + fi + echo "TUNNEL_ID=${tunnel_id}" >> "$GITHUB_ENV" + + token_resp=$(curl -fsS \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/token" \ + -H "Authorization: Bearer ${CF_API_TOKEN}") + tunnel_token=$(jq -r '.result' <<< "$token_resp") + echo "::add-mask::${tunnel_token}" + echo "TUNNEL_TOKEN=${tunnel_token}" >> "$GITHUB_ENV" + + curl -fsS -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" \ + '{config: {ingress: [{hostname: $host, service: "http://localhost:8000"}, {service: "http_status:404"}]}}')" \ + > /dev/null + + - name: Point DNS at the tunnel + id: cf_dns + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + PREVIEW_HOSTNAME: smoketest-${{ github.run_id }}.${{ env.PREVIEW_DOMAIN }} + run: | + zone_id=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=github.fyi" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id') + if [ -z "$zone_id" ] || [ "$zone_id" = "null" ]; then + echo "::error::Could not resolve zone id for github.fyi" + exit 1 + fi + echo "ZONE_ID=${zone_id}" >> "$GITHUB_ENV" + + record_resp=$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" --arg target "${TUNNEL_ID}.cfargotunnel.com" \ + '{type: "CNAME", name: $host, content: $target, proxied: true}')") + record_id=$(jq -r '.result.id' <<< "$record_resp") + if [ -z "$record_id" ] || [ "$record_id" = "null" ]; then + echo "::error::DNS record creation failed: $record_resp" + exit 1 + fi + echo "DNS_RECORD_ID=${record_id}" >> "$GITHUB_ENV" + + echo "## Smoketest URL" >> "$GITHUB_STEP_SUMMARY" + echo "https://${PREVIEW_HOSTNAME}" >> "$GITHUB_STEP_SUMMARY" + echo "Visiting it should challenge you with Cloudflare Access GitHub SSO," >> "$GITHUB_STEP_SUMMARY" + echo "then show 'Tunnel smoketest OK' once you're through." >> "$GITHUB_STEP_SUMMARY" + echo "Live for up to 15 minutes (this job's timeout)." >> "$GITHUB_STEP_SUMMARY" + echo "url=https://${PREVIEW_HOSTNAME}" >> "$GITHUB_OUTPUT" + + - name: Compute deployment timestamps + id: timestamps + run: | + echo "deployed_at=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" + echo "expires_at=$(date -u -d '+15 minutes' +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" + + - name: Comment smoketest link on PR + if: github.event.inputs.pr_number != '' + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: k8s-preview-smoketest + number_force: ${{ github.event.inputs.pr_number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + message: | + **Tunnel smoketest** (static page, not the real stack): + ${{ steps.cf_dns.outputs.url }} + + Deployed: ${{ steps.timestamps.outputs.deployed_at }} · Expires: ${{ steps.timestamps.outputs.expires_at }} + + Sign-in via Cloudflare Access (GitHub SSO), then should show "Tunnel smoketest OK". + + - name: Run tunnel until the job times out + run: /tmp/cloudflared tunnel --no-autoupdate run --token "${TUNNEL_TOKEN}" + + - name: Delete DNS record + if: always() + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + run: | + [ -n "${ZONE_ID:-}" ] && [ -n "${DNS_RECORD_ID:-}" ] || exit 0 + curl -fsS -X DELETE \ + "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${DNS_RECORD_ID}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" || true + + - name: Delete Cloudflare Tunnel + if: always() + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }} + run: | + [ -n "${TUNNEL_ID:-}" ] || exit 0 + curl -fsS -X DELETE \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" || true diff --git a/.github/workflows/k8s-preview.yaml b/.github/workflows/k8s-preview.yaml new file mode 100644 index 0000000..3347767 --- /dev/null +++ b/.github/workflows/k8s-preview.yaml @@ -0,0 +1,367 @@ +name: K8s Stack Preview +# Deploys the full Nebari platform stack (Keycloak + nic-operator + Envoy +# Gateway, via nebari-dev/action-nebari-sandbox's `platform` profile) plus +# this PR's chart into an ephemeral k3d cluster on the runner, then exposes +# JupyterHub through a per-PR Cloudflare Tunnel behind Cloudflare Access +# (GitHub-org SSO) so a reviewer can click a link and use it. +# +# This repo is public, so a plain shared secret posted in the PR comment +# (a quick-tunnel URL, a basic-auth password) is readable by anyone who +# opens the PR, not just intended reviewers. Access closes that gap by +# authenticating the *person*, not a string in the comment: Cloudflare +# challenges every request to *.github.fyi with a GitHub SSO login and +# only lets it through to cloudflared if the signed-in account is a +# member of this GitHub org. The PR comment only ever contains a URL. +# +# Hostnames are single-level (pr-.github.fyi, not pr-.dspack.github.fyi) +# deliberately: Cloudflare's free Universal SSL only auto-covers the zone +# apex plus one wildcard level (github.fyi + *.github.fyi); a second level +# needs the paid Advanced Certificate Manager add-on, which this setup +# doesn't use. +# +# Scope, deliberately: the tunnel points straight at the `proxy-public` +# service (dummy-authenticator login, same as local Tilt dev). The chart +# deploys with nebariapp.enabled=false — nebariapp.auth.enabled=true +# was tried to additionally exercise the operator/Keycloak reconcile, +# but 00-gateway-auth.py reads /etc/oauth/issuer-url unconditionally at +# import time, before the operator's async client provisioning can ever +# populate it, so the hub pod crash-loops. Not worth chasing for a +# preview link; the operator/OIDC path stays untested here. +# +# The link only lives for the run's duration (bounded by timeout-minutes +# below) — it is not a persistent per-PR environment. Each run creates its +# own Cloudflare Tunnel + DNS record (so concurrent previews on different +# PRs don't collide on the same route) and deletes both on cleanup. +# +# One-time setup this workflow assumes already exists (Cloudflare Zero +# Trust dashboard, done by a repo admin, not scripted here). The tunnel, +# the `github.fyi` zone, and Zero Trust/Access all live in ONE Cloudflare +# account (aktechlabs) — not the account behind CLOUDFLARE_API_TOKEN / +# CLOUDFLARE_ACCOUNT_ID, which docs.yml uses for Pages: +# - Zone `github.fyi` in that account, with an Access self-hosted +# application for `*.github.fyi`, GitHub as identity provider, +# policy scoped to this org. Note this wildcard covers ANY +# single-label subdomain of github.fyi, not just previews — fine as +# long as github.fyi isn't also hosting unrelated services outside +# this org's control. +# - Secret CLOUDFLARE_TUNNEL_ACCOUNT_ID: that account's id (the +# `cfd_tunnel` API is account-scoped; can't be derived from the +# token alone). +# - Secret CLOUDFLARE_TUNNEL_API_TOKEN: a custom token scoped to +# EXACTLY three permissions, nothing broader: +# * Account -> Cloudflare Tunnel -> Edit +# * Zone -> Zone -> Read (to resolve the zone id by name) +# * Zone -> DNS -> Edit (to create/delete the CNAME record) +# Zone Resources: Include -> Specific zone -> github.fyi. +# Account Resources: Include -> Specific account -> aktechlabs. +# +# Only runs when a maintainer/collaborator adds the `deploy-preview` label +# (GitHub restricts who can label a PR) — arbitrary PR authors, including +# from forks, cannot trigger this themselves. Even so, a fork PR still runs +# attacker-authored code once labeled; the comment flags this so whoever +# labels it is doing so knowingly. +# +# Residual risk not covered here: k3d pods share the runner's Docker daemon +# rather than being hardware-isolated, and k3d's default CNI (flannel) +# does not enforce NetworkPolicy, so a container escape or outbound abuse +# from inside a spawned notebook pod is not blocked at the network layer. +# Per-job GITHUB_TOKEN permissions are scoped to the minimum each job +# needs so a compromised runner in the exposed 90-minute window can't use +# an ambient token to touch other workflows or repo state. + +on: + pull_request: + types: [labeled, unlabeled, synchronize] + +concurrency: + group: k8s-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + PREVIEW_LABEL: deploy-preview + PREVIEW_DOMAIN: github.fyi + CLOUDFLARED_VERSION: "2026.7.3" + # sha256 of cloudflared-linux-amd64 for the pinned version above, + # computed from the official release asset at + # https://github.com/cloudflare/cloudflared/releases/tag/2026.7.3 + CLOUDFLARED_SHA256: "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17" + +jobs: + deploy-preview: + if: contains(github.event.pull_request.labels.*.name, 'deploy-preview') && github.event.action != 'unlabeled' + name: Deploy preview + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Provision sandbox (k3d + full NIC platform stack) + id: sandbox + uses: nebari-dev/action-nebari-sandbox@dbdb054c16efee3b29e26aee406c12fb9639bfd0 # v2.2.0 + with: + profile: platform + cluster-name: pr-preview-${{ github.event.pull_request.number }} + + - name: Build hub image from this PR + run: docker build --target jupyterhub -t nebari-data-science-pack-jupyterhub:preview images/ + + - name: Install k3d CLI (for image import) + run: | + curl -fsSL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | \ + TAG=v5.8.3 bash + + - name: Side-load hub image into the sandbox cluster + run: k3d image import nebari-data-science-pack-jupyterhub:preview -c ${{ steps.sandbox.outputs.cluster-name }} + + # charts/ is gitignored (dependency .tgz files aren't committed), so a + # fresh checkout needs this before `helm upgrade --install` can find + # the jupyterhub subchart. Resolves against the version/digest already + # pinned in the committed Chart.lock, not a new or bumped dependency. + - name: Fetch chart dependencies + run: | + helm repo add jupyterhub https://hub.jupyter.org/helm-chart/ + helm dependency build . + + - name: Deploy chart + id: deploy + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + helm upgrade --install preview . \ + --namespace pr-preview --create-namespace \ + --set jupyterhub.hub.image.name=nebari-data-science-pack-jupyterhub \ + --set jupyterhub.hub.image.tag=preview \ + --set nebariapp.enabled=false \ + --set jupyterhub.custom.external-url="pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }}" \ + --wait --timeout 5m + + - name: Wait for hub + proxy + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + kubectl -n pr-preview rollout status deployment/hub --timeout=180s + kubectl -n pr-preview rollout status deployment/proxy --timeout=180s + + # Cleanup deletes the whole cluster next, so this is the only chance + # to see why a pod/job didn't reach Ready if `helm --wait` timed out. + - name: Debug pod/job status on deploy failure + if: failure() + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + kubectl -n pr-preview get pods -o wide || true + kubectl -n pr-preview get jobs || true + kubectl -n pr-preview get events --sort-by=.lastTimestamp || true + for pod in $(kubectl -n pr-preview get pods -o name 2>/dev/null); do + echo "--- describe $pod ---" + kubectl -n pr-preview describe "$pod" || true + echo "--- logs $pod ---" + kubectl -n pr-preview logs "$pod" --all-containers --tail=100 || true + done + + # Interactive SSH debug session into the live runner (cluster still + # up, KUBECONFIG still valid) instead of guessing blind from static + # logs. limit-access-to-actor restricts the SSH session to whoever + # triggered this run — required on a public repo. Bounded to 20min + # so a forgotten session doesn't eat the whole 90min job timeout. + - name: Debug via tmate SSH on deploy failure + if: failure() + uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24 + timeout-minutes: 20 + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + with: + limit-access-to-actor: true + + - name: Port-forward JupyterHub proxy + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + kubectl -n pr-preview port-forward svc/proxy-public 8000:80 \ + > /tmp/port-forward.log 2>&1 & + echo "PORT_FORWARD_PID=$!" >> "$GITHUB_ENV" + + - name: Install cloudflared + run: | + curl -fsSL -o /tmp/cloudflared \ + "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64" + echo "${CLOUDFLARED_SHA256} /tmp/cloudflared" | sha256sum -c - + chmod +x /tmp/cloudflared + + # A per-run named Tunnel (not the anonymous quick-tunnel) so: + # (a) it can sit behind an Access application (quick tunnels have no + # account/zone attached, so no policy can be bound to them), and + # (b) each PR gets its own tunnel + hostname, so two PRs previewing + # at once don't share one route and cross-talk. + - name: Create Cloudflare Tunnel for this PR + id: cf_tunnel + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }} + PREVIEW_HOSTNAME: pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }} + run: | + tunnel_secret=$(openssl rand -base64 32) + echo "::add-mask::${tunnel_secret}" + + tunnel_name="pr-${{ github.event.pull_request.number }}-${{ github.run_id }}" + create_resp=$(curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg name "$tunnel_name" --arg secret "$tunnel_secret" \ + '{name: $name, config_src: "cloudflare", tunnel_secret: $secret}')") + tunnel_id=$(jq -r '.result.id // empty' <<< "$create_resp") + + # A GitHub Actions retry reuses the same run_id (only run_attempt + # changes), so a re-run after the first attempt already created + # this tunnel (and didn't get to clean it up) hits a 409 name + # conflict here. Reuse the existing tunnel by name instead of + # failing — it doesn't need the original tunnel_secret, just a + # fresh --token from the /token endpoint below. + if [ -z "$tunnel_id" ]; then + echo "::warning::Tunnel create failed (likely a name conflict from a retry), looking up existing tunnel named ${tunnel_name}: $create_resp" + tunnel_id=$(curl -fsS "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?name=${tunnel_name}&is_deleted=false" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id // empty') + fi + if [ -z "$tunnel_id" ]; then + echo "::error::Tunnel creation failed and no existing tunnel named ${tunnel_name} found: $create_resp" + exit 1 + fi + echo "tunnel_id=${tunnel_id}" >> "$GITHUB_OUTPUT" + echo "TUNNEL_ID=${tunnel_id}" >> "$GITHUB_ENV" + + token_resp=$(curl -fsS \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/token" \ + -H "Authorization: Bearer ${CF_API_TOKEN}") + tunnel_token=$(jq -r '.result' <<< "$token_resp") + echo "::add-mask::${tunnel_token}" + echo "TUNNEL_TOKEN=${tunnel_token}" >> "$GITHUB_ENV" + + curl -fsS -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" \ + '{config: {ingress: [{hostname: $host, service: "http://localhost:8000"}, {service: "http_status:404"}]}}')" \ + > /dev/null + + - name: Point DNS at the tunnel + id: cf_dns + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + PREVIEW_HOSTNAME: pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }} + run: | + zone_id=$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=github.fyi" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" | jq -r '.result[0].id') + if [ -z "$zone_id" ] || [ "$zone_id" = "null" ]; then + echo "::error::Could not resolve zone id for github.fyi" + exit 1 + fi + echo "ZONE_ID=${zone_id}" >> "$GITHUB_ENV" + + record_resp=$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg host "$PREVIEW_HOSTNAME" --arg target "${TUNNEL_ID}.cfargotunnel.com" \ + '{type: "CNAME", name: $host, content: $target, proxied: true}')") + record_id=$(jq -r '.result.id' <<< "$record_resp") + if [ -z "$record_id" ] || [ "$record_id" = "null" ]; then + echo "::error::DNS record creation failed: $record_resp" + exit 1 + fi + echo "DNS_RECORD_ID=${record_id}" >> "$GITHUB_ENV" + echo "url=https://${PREVIEW_HOSTNAME}" >> "$GITHUB_OUTPUT" + + # The URL itself (pr-.github.fyi) is identical on every run, so + # without a timestamp the sticky comment would post byte-identical + # text each redeploy and look like it never updated. + - name: Compute deployment timestamps + id: timestamps + run: | + echo "deployed_at=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" + echo "expires_at=$(date -u -d '+90 minutes' +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" + + - name: Comment preview link on PR + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: k8s-preview + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + message: | + **K8s stack preview** for `${{ github.event.pull_request.head.ref }}`: + ${{ steps.cf_dns.outputs.url }} + + Deployed: ${{ steps.timestamps.outputs.deployed_at }} · Expires: ${{ steps.timestamps.outputs.expires_at }} + + ${{ github.event.pull_request.head.repo.fork && '⚠️ **This PR is from a fork** — the code running in this preview is not from a trusted maintainer branch.' || '' }} + + You'll be asked to sign in via Cloudflare Access (GitHub SSO) before + reaching JupyterHub — only members of this GitHub org get through. + **Then JupyterHub login:** dummy authenticator, any username + any password. + + This goes straight to JupyterHub's proxy; the operator-provisioned + NebariApp/OIDC route isn't deployed here (see workflow header comment + for why) — this preview doesn't exercise operator/Keycloak reconcile. + + Live until the expiry time above, or until the `deploy-preview` label + is removed. Push a new commit or re-add the label to redeploy. + + - name: Run tunnel until the job times out + run: /tmp/cloudflared tunnel --no-autoupdate run --token "${TUNNEL_TOKEN}" + + - name: Delete DNS record + if: always() + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + run: | + [ -n "${ZONE_ID:-}" ] && [ -n "${DNS_RECORD_ID:-}" ] || exit 0 + curl -fsS -X DELETE \ + "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${DNS_RECORD_ID}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" || true + + - name: Delete Cloudflare Tunnel + if: always() + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }} + run: | + [ -n "${TUNNEL_ID:-}" ] || exit 0 + curl -fsS -X DELETE \ + "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" || true + + - name: Cleanup cluster + if: always() + run: k3d cluster delete ${{ steps.sandbox.outputs.cluster-name }} || true + + cleanup-preview: + if: github.event.action == 'unlabeled' && github.event.label.name == 'deploy-preview' + name: Stop preview + runs-on: ubuntu-latest + permissions: + pull-requests: write + actions: write + steps: + - name: Cancel the in-flight preview run for this PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + run_id=$(gh api "repos/${{ github.repository }}/actions/runs?event=pull_request&status=in_progress" \ + --jq '.workflow_runs[] | select(.name == "K8s Stack Preview") | select(.pull_requests[]?.number == ${{ github.event.pull_request.number }}) | .id' \ + | head -1) + if [ -n "$run_id" ]; then + gh run cancel "$run_id" --repo "${{ github.repository }}" + fi + + - name: Comment that the preview stopped + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: k8s-preview + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + message: | + **K8s stack preview** stopped — the `deploy-preview` label was removed. + + Add it again to redeploy.