diff --git a/.github/workflows/k8s-preview-tunnel-smoketest.yaml b/.github/workflows/k8s-preview-tunnel-smoketest.yaml new file mode 100644 index 0000000..30daeb4 --- /dev/null +++ b/.github/workflows/k8s-preview-tunnel-smoketest.yaml @@ -0,0 +1,176 @@ +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: + # Same repo Variable k8s-preview.yaml uses (Settings -> Actions -> + # Variables -> PREVIEW_DOMAIN) so the two workflows can't drift apart. + PREVIEW_DOMAIN: ${{ vars.PREVIEW_DOMAIN }} + 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=${PREVIEW_DOMAIN}" \ + -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 ${PREVIEW_DOMAIN}" + 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..1df0825 --- /dev/null +++ b/.github/workflows/k8s-preview.yaml @@ -0,0 +1,647 @@ +name: K8s Stack Preview +# Deploys the full Nebari platform stack (Keycloak + nic-operator + Envoy +# Gateway, via nebari-dev/action-nebari-sandbox) plus this PR's chart into +# an ephemeral kind cluster on the runner, then exposes +# JupyterHub through a per-PR Cloudflare Tunnel behind Cloudflare Access +# (GitHub 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 *. with a GitHub SSO +# login and only lets it through to cloudflared if the signed-in account +# matches the Access application's policy (currently an explicit email +# allow-list, not org membership -- see the Cloudflare Access application +# for this domain for who's currently allowed). The PR comment only ever +# contains a URL. +# +# The domain is a repo Variable (Settings -> Secrets and variables -> +# Actions -> Variables -> PREVIEW_DOMAIN, currently openteams.app), not +# hardcoded, so it can be repointed without editing this file. Hostnames +# built from it stay single-level (pr--data-science-pack., +# never pr-.data-science-pack.) deliberately: Cloudflare's +# free Universal SSL only auto-covers the zone apex plus one wildcard +# level ( + *.); a second level needs the paid Advanced +# Certificate Manager add-on, which this setup doesn't use. +# +# Scope: the tunnel points straight at the `proxy-public` service. The +# chart deploys with nebariapp.enabled=true (nebariapp.auth.enabled=true +# by chart default), so login goes through the real operator-provisioned +# Keycloak OIDC client, exercising the same auth path production deploys +# use. Getting this working required: (1) not blocking `helm upgrade +# --install` with `--wait`, since the hub pod crash-loops on +# FileNotFoundError until the operator's async client-provisioning +# Secret exists -- a separate step now polls for that Secret and force- +# restarts hub once it appears; (2) labelling the release namespace +# `nebari.dev/managed=true` before install, since nic-operator otherwise +# never reconciles the NebariApp at all (condition NamespaceNotOptedIn), +# which is a permanent gate, not a slow-provisioning race. +# +# The link only lives for 20 minutes (the tunnel step's own timeout); 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 PREVIEW_DOMAIN zone, and Zero Trust/Access all live in ONE +# Cloudflare account (OpenTeams Account), not the account behind +# CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID, which docs.yml uses for +# Pages: +# - Variable PREVIEW_DOMAIN (repo Settings -> Actions -> Variables): +# the zone name, e.g. openteams.app. +# - That zone in the Cloudflare account, with an Access self-hosted +# application for `*.`, GitHub as identity provider, +# policy scoped to this org (nebari-dev). Note this wildcard covers +# ANY single-label subdomain of the zone, not just previews, fine +# as long as the zone 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 -> the PREVIEW_DOMAIN zone. +# Account Resources: Include -> Specific account -> OpenTeams Account. +# +# 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: kind nodes share the runner's Docker daemon +# rather than being hardware-isolated, and kind's default CNI (kindnet) +# 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 20-minute window can't use +# an ambient token to touch other workflows or repo state. + +on: + pull_request: + types: [labeled, unlabeled, synchronize] + +# cancel-in-progress is normally what we want (one live preview per PR, +# newest commit wins), but it operates on the whole workflow RUN, not on +# whether deploy-preview's job condition ends up true. Adding the +# extend-preview label (see the tunnel step) fires a `labeled` event just +# like any other label add, which would otherwise queue a new run here and +# cancel the very run extend-preview was meant to extend -- even though +# that new run's job condition skips it and does nothing. Excluding that +# one case lets the new (skipped) run queue harmlessly behind the current +# one instead of cancelling it. +concurrency: + group: k8s-preview-${{ github.event.pull_request.number }} + cancel-in-progress: ${{ !(github.event.action == 'labeled' && github.event.label.name == 'extend-preview') }} + +env: + PREVIEW_LABEL: deploy-preview + PREVIEW_DOMAIN: ${{ vars.PREVIEW_DOMAIN }} + 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: + # A `labeled` event fires the whole workflow for ANY label add, not just + # deploy-preview -- e.g. someone adding extend-preview (see the tunnel + # step below) would otherwise start a second run of this job, which + # `concurrency: cancel-in-progress` then uses to cancel the very run + # extend-preview was meant to extend. Scope labeled/unlabeled triggers to + # the deploy-preview label itself; extend-preview is read by the + # already-running tunnel step polling the API, not by starting a new run. + deploy-preview: + if: >- + contains(github.event.pull_request.labels.*.name, 'deploy-preview') && + github.event.action != 'unlabeled' && + (github.event.action != 'labeled' || github.event.label.name == 'deploy-preview') + name: Deploy preview + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + pull-requests: write + issues: write + deployments: write + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + # So it shows up as an existing, pickable label in the PR's label + # picker instead of requiring someone to freehand-type a brand-new + # name (which GitHub does support, but it's an easy thing to miss). + # `gh label create` errors if the label already exists; that's fine, + # this step is just making sure it's there. + - name: Ensure the extend-preview label exists + run: | + python -m scripts.preview.github_api ensure-label-exists \ + --repo "${{ github.repository }}" --token "${{ secrets.GITHUB_TOKEN }}" \ + --name extend-preview --color BFD4F2 \ + --description "Push this preview's expiry back 20 minutes" + + # v0.32.0+ required: the sandbox's kind cluster uses containerd's + # config v4 format, which `kind load` on older CLI releases can't + # parse ("ERROR: unknown containerd config version: 4"). + - name: Install kind + uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + version: v0.33.0 + install_only: true + + - name: Provision sandbox (kind + full NIC platform stack) + id: sandbox + uses: nebari-dev/action-nebari-sandbox@9ac369ebf87ac2ae217504dcbf824c77f70e429a # v3.0.0 + with: + cluster-name: pr-preview-${{ github.event.pull_request.number }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + # Every run gets a fresh runner (empty Docker daemon), so a plain + # `docker build` re-runs every layer -- including the apt/pixi + # installs -- from scratch every time. GitHub Actions cache (type=gha) + # persists layers across runs so an unchanged pixi.lock/pixi.toml + # reuses the previous run's install instead of redoing it. + - name: Build hub image from this PR + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: images/ + target: jupyterhub + tags: nebari-data-science-pack-jupyterhub:preview + load: true + cache-from: type=gha,scope=k8s-preview-jupyterhub + cache-to: type=gha,scope=k8s-preview-jupyterhub,mode=max + + - name: Side-load hub image into the sandbox cluster + run: kind load docker-image nebari-data-science-pack-jupyterhub:preview --name ${{ 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 . + + # nic-operator only reconciles NebariApps in namespaces opted into + # Nebari management; without this label it sets condition + # NamespaceNotOptedIn and never provisions the Keycloak client at + # all (confirmed via `kubectl get nebariapp -o yaml` after a deploy + # failure -- not a slow-provisioning race, a permanent gate that + # would never resolve on its own). + - name: Create + label the preview namespace for the operator + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + kubectl create namespace pr-preview --dry-run=client -o yaml | kubectl apply -f - + kubectl label namespace pr-preview nebari.dev/managed=true --overwrite + + # Why this is needed (ArgoCD selfHeal reverting a direct kubectl + # patch, Keycloak's KC_HOSTNAME being otherwise unreachable from a + # real browser) is explained in scripts/preview/keycloak_gitops.py's + # module docstring, next to the logic itself. + - name: Point Keycloak's own hostname at the public tunnel route + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + python -m scripts.preview.keycloak_gitops patch \ + --gitops-dir "$HOME/.nic/gitops/${{ steps.sandbox.outputs.cluster-name }}" \ + --kc-public-url "https://keycloak-pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" + + # No --wait here: with nebariapp.auth.enabled=true, the hub pod reads + # the operator-provisioned Keycloak client Secret at import time + # (config/jupyterhub/00-gateway-auth.py) and crashes with + # FileNotFoundError if it starts before the operator has created it -- + # the Secret volume is mounted `optional: true` so the pod itself + # comes up fine, only the hub container's Python process crash-loops. + # That race is inherent (operator reconciliation runs concurrently + # with the chart install, not before it), so we wait for the Secret + # separately below instead of blocking helm on hub's rollout status. + # + # jupyterhub.hub.extraVolumes[1] (values.yaml) hardcodes + # secretName: data-science-pack-nebari-data-science-pack-oidc-client + # -- a literal string baked in for a release named "data-science-pack", + # not templated off .Release.Name. Every restart/retry against the + # real (correctly-populated) operator secret still 404'd on the mount + # because the pod was mounting a DIFFERENT, nonexistent secret name + # the whole time (release name here is "preview") -- confirmed only + # after ruling out timing races and kubelet caching across 5 retries. + # Override it explicitly to match this release's actual name. Using + # --set with list-index syntax (extraVolumes[1].secret.secretName=...) + # against a values.yaml-defined list corrupts the OTHER elements' + # `name` fields too (Helm's --set doesn't deep-merge per-element into + # an existing default list) -- confirmed by a real failure: + # "volumes[2].name: Required value, volumes[3].name: Required value". + # --set-json with the full, correct array sidesteps that. + # + # keycloak.backchannelURL: Keycloak's public hostname sits behind + # Cloudflare Access (same as the JupyterHub hostname), which is fine + # for the browser-facing authorize/login legs -- the reviewer already + # has an Access session -- but hub's OWN server-to-server token + # exchange has no such session and gets blocked/redirected by + # Access instead of getting a JSON response back, confirmed live: + # json.decoder.JSONDecodeError: Expecting value: line 1 column 1 + # (an empty/non-JSON body from the token endpoint). Point the + # backchannel (token_url, userdata_url only -- authorize_url stays + # on the public issuer) straight at Keycloak's in-cluster Service, + # bypassing Cloudflare/Access entirely for that leg. + - 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=true \ + --set nebariapp.hostname="pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ + --set jupyterhub.custom.external-url="pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ + --set keycloak.backchannelURL="http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080" \ + --set-json 'jupyterhub.hub.extraVolumes=[{"name":"custom-config","configMap":{"name":"nebari-data-science-pack-hub-config"}},{"name":"oauth-client","secret":{"secretName":"preview-nebari-data-science-pack-oidc-client","optional":true}},{"name":"org-ca","configMap":{"name":"nebari-trust-bundle","optional":true}},{"name":"ca-merged","emptyDir":{}}]' + + # Secret name convention: {Release.Name}-{Chart.Name}-oidc-client + # (see values.yaml, jupyterhub.hub.extraVolumes comment). Poll by + # label-free name match rather than assuming a fixed provisioning + # time -- the operator's reconcile loop has no SLA. + # Checking the Secret merely EXISTS isn't enough: the operator creates + # it with client-id/client-secret on its first reconcile pass, then + # patches in issuer-url on a later pass (GetExternalIssuerURL). A hub + # restart triggered right after the Secret's first appearance still + # hits FileNotFoundError on /etc/oauth/issuer-url specifically -- wait + # for that key's actual (non-empty) value, not just the object. + # Why a mere Secret-exists check or a single restart isn't reliable + # here (the operator populates issuer-url on a later reconcile pass; + # kubelet's Secret volume cache can hand a fresh pod a stale + # snapshot) is explained in scripts/preview/k8s_wait.py. + - name: Wait for operator to provision the Keycloak client secret + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + python -m scripts.preview.k8s_wait wait-for-secret-key \ + --namespace pr-preview --secret preview-nebari-data-science-pack-oidc-client \ + --key issuer-url --timeout-s 180 --poll-interval-s 5 + + - name: Restart hub until it picks up the operator secret + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + python -m scripts.preview.k8s_wait restart-until-ready \ + --namespace pr-preview --deployment hub --rollout-timeout-s 90 --max-attempts 5 + + - name: Wait for proxy + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: kubectl -n pr-preview rollout status deployment/proxy --timeout=180s + + - 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" + sleep 3 + + # Keycloak itself, so a reviewer can sign in there directly and land + # on JupyterHub already authenticated (same Keycloak SSO cookie the + # hub's own OAuth redirect relies on). Keycloak's own hostname was + # repointed at this same public route earlier (see "Point Keycloak's + # own hostname..."), so no Host-header rewrite is needed here -- the + # request just flows straight through with its real Host header. + - name: Port-forward Keycloak + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + kubectl -n keycloak port-forward svc/keycloak-keycloakx-http 8001:8080 \ + > /tmp/port-forward-keycloak.log 2>&1 & + echo "KEYCLOAK_PORT_FORWARD_PID=$!" >> "$GITHUB_ENV" + sleep 3 + + # Cloudflare Access is the real security boundary for this preview + # (only allow-listed accounts reach the tunnel at all) -- a simple, + # known password for the Keycloak-side login is fine here, so + # reviewers don't need to hunt for real credentials on a throwaway + # cluster. The admin password is piped straight from the Secret into + # the token request and never echoed or logged. + - name: Create a test login user in Keycloak + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + admin_password=$(kubectl -n keycloak get secret keycloak-admin-credentials -o jsonpath='{.data.admin-password}' | base64 -d) + python -m scripts.preview.keycloak create-reviewer-user \ + --base-url "http://localhost:8001" --realm nebari --admin-password "$admin_password" + + # jhub-apps runs as a managed service subprocess inside the hub pod, + # not its own Deployment, so a crash there doesn't fail `helm --wait` + # or the rollout checks above -- it only shows up as a 502 on + # /services/japps/* once someone hits it. Hit its root path directly + # (bypassing Cloudflare, straight to CHP) so a crashed/never-bound + # uvicorn process shows up here instead of only from a live login. + - name: Smoke-test jhub-apps service + if: always() + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: | + echo "--- GET /services/japps/ ---" + curl -sS -o /tmp/japps-root.html -w 'HTTP %{http_code}\n' http://localhost:8000/services/japps/ || true + head -c 2000 /tmp/japps-root.html || true + echo + echo "--- japps process in hub pod ---" + kubectl -n pr-preview exec deploy/hub -- ps aux | grep -i "uvicorn\|japps" || true + + - name: Dump hub logs (jhub-apps startup) + if: always() + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: kubectl -n pr-preview logs deployment/hub --tail=500 + + # 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: | + # ArgoCD Application sync status -- the sandbox's own + # wait-platform.sh only polls for K8s resources existing per + # namespace, never checks whether ArgoCD ever actually synced + # the Application that's supposed to create them. Confirmed + # live: Keycloak's namespace had 0 resources after a 600s wait + # with no error anywhere else in the log -- this is the only + # place that would show whether ArgoCD ever attempted the sync + # at all, and why, if it didn't. + echo "=== ArgoCD Applications ===" + kubectl -n argocd get applications -o wide || true + for app in keycloak nebari-operator cloudnative-pg; do + echo "--- ArgoCD application/$app ---" + kubectl -n argocd get application "$app" -o yaml || true + done + + for ns in pr-preview keycloak; do + echo "=== namespace: $ns ===" + kubectl -n "$ns" get pods -o wide || true + kubectl -n "$ns" get jobs || true + kubectl -n "$ns" get secrets || true + kubectl -n "$ns" get events --sort-by=.lastTimestamp || true + for pod in $(kubectl -n "$ns" get pods -o name 2>/dev/null); do + echo "--- describe $pod ($ns) ---" + kubectl -n "$ns" describe "$pod" || true + echo "--- logs $pod ($ns) ---" + kubectl -n "$ns" logs "$pod" --all-containers --tail=100 || true + done + done + kubectl -n pr-preview get nebariapp -o yaml || true + + echo "=== nebari-operator deployment env ===" + for d in $(kubectl get deploy -A -o json | jq -r '.items[] | select(.metadata.name | test("operator")) | "\(.metadata.namespace)/\(.metadata.name)"'); do + ns="${d%%/*}"; name="${d##*/}" + echo "--- $d ---" + kubectl -n "$ns" get deploy "$name" -o jsonpath='{.spec.template.spec.containers[0].env}' | jq . || true + done + + echo "=== oidc-client secret: keys + issuer-url presence (no values printed) ===" + kubectl -n pr-preview get secret preview-nebari-data-science-pack-oidc-client -o json 2>/dev/null | jq -r '.data | keys' || true + issuer_b64=$(kubectl -n pr-preview get secret preview-nebari-data-science-pack-oidc-client -o jsonpath='{.data.issuer-url}' 2>/dev/null) + echo "issuer-url key present: $([ -n "$issuer_b64" ] && echo yes || echo no); decoded byte length: $(echo -n "$issuer_b64" | base64 -d 2>/dev/null | wc -c)" + + echo "=== NIC config domain ===" + find /tmp -maxdepth 1 -iname "nic-config*.yaml" -exec grep -H "^domain:" {} \; || true + + # 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: 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 }} + run: | + python -m scripts.preview.cloudflare create-tunnel \ + --account-id "$CF_ACCOUNT_ID" --api-token "$CF_API_TOKEN" \ + --name "pr-${{ github.event.pull_request.number }}-${{ github.run_id }}" \ + --preview-hostname "pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ + --preview-service "http://localhost:8000" \ + --keycloak-hostname "keycloak-pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ + --keycloak-service "http://localhost:8001" + + - name: Point DNS at the tunnel + id: cf_dns + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + run: | + python -m scripts.preview.cloudflare create-dns \ + --api-token "$CF_API_TOKEN" --domain "${{ env.PREVIEW_DOMAIN }}" \ + --preview-hostname "pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ + --keycloak-hostname "keycloak-pr-${{ github.event.pull_request.number }}-data-science-pack.${{ env.PREVIEW_DOMAIN }}" \ + --target "${TUNNEL_ID}.cfargotunnel.com" + + # The URL itself (pr--data-science-pack.) 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. + # ISO timestamps feed in the comment below -- GitHub's + # own web component (used all over its UI for "3 minutes ago") that + # ticks live client-side once rendered, confirmed via `gh api /markdown` + # to survive comment sanitization unstripped. Beats a static UTC + # string the reader has to convert to their own timezone by hand, and + # unlike re-editing the comment every minute, needs no extra CI runs. + # The plain UTC strings are the fallback text shown before JS + # hydrates . + - 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 '+20 minutes' +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" + echo "deployed_at_iso=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + echo "expires_at_iso=$(date -u -d '+20 minutes' +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + + # A GitHub Deployment/Environment, not just the comment below: GitHub + # renders this as its own "View deployment" box pinned near the top + # of the PR (like Vercel's bot), so the latest link + status is + # visible without scrolling into the comment thread -- unlike the + # sticky comment, which is an in-place edit of a comment created on + # the PR's first deploy, so it doesn't move and is easy to miss. + # `required_contexts: []` skips gating this on other checks for the + # same commit (e.g. lint/test workflows still running) -- this is a + # preview link, not a merge gate. auto_inactive (default true on the + # status call) marks any prior deployment to this same per-PR + # environment inactive, so redeploys don't leave stale green boxes. + - name: Create GitHub deployment + run: | + python -m scripts.preview.github_api create-and-activate \ + --repo "${{ github.repository }}" --token "${{ secrets.GITHUB_TOKEN }}" \ + --ref "${{ github.event.pull_request.head.sha }}" \ + --environment "pr-${{ github.event.pull_request.number }}-preview" \ + --task "deploy:preview" --description "K8s stack preview" \ + --environment-url "${{ steps.cf_dns.outputs.url }}" \ + --log-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --status-description "Live for 20 minutes" + + - name: Render the ready-preview comment body + id: render_ready + run: | + python -m scripts.preview.comment render-ready \ + --url "${{ steps.cf_dns.outputs.url }}" --keycloak-url "${{ steps.cf_dns.outputs.keycloak_url }}" \ + --deployed-at "${{ steps.timestamps.outputs.deployed_at }}" \ + --deployed-at-iso "${{ steps.timestamps.outputs.deployed_at_iso }}" \ + --expires-at "${{ steps.timestamps.outputs.expires_at }}" \ + --expires-at-iso "${{ steps.timestamps.outputs.expires_at_iso }}" \ + ${{ github.event.pull_request.head.repo.fork && '--fork' || '' }} + + - name: Comment preview link on PR + id: comment_preview + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: k8s-preview + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + message: ${{ steps.render_ready.outputs.body }} + + # Bounded to 20min by default so the live preview doesn't sit open + # (and burn CI minutes) indefinitely, and so this step ends itself + # well under the job's 90min timeout-minutes -- letting the job's own + # timeout kill this step instead makes GitHub mark the whole run + # "cancelled" with a "job has exceeded the maximum execution time" + # failure annotation, which looks like a real failure even though + # nothing broke -- the preview is *meant* to expire eventually. + # Extend the deadline any time before then via the extend-preview + # label; see scripts/preview/tunnel.py. + - name: Run tunnel until it times out (extend via the extend-preview label) + run: | + python -m scripts.preview.tunnel run \ + --cloudflared /tmp/cloudflared --token "${TUNNEL_TOKEN}" \ + --repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" \ + --github-token "${{ secrets.GITHUB_TOKEN }}" \ + --url "${{ steps.cf_dns.outputs.url }}" --keycloak-url "${{ steps.cf_dns.outputs.keycloak_url }}" \ + --deployed-at "${{ steps.timestamps.outputs.deployed_at }}" \ + --deployed-at-iso "${{ steps.timestamps.outputs.deployed_at_iso }}" \ + ${{ github.event.pull_request.head.repo.fork && '--fork' || '' }} \ + --initial-seconds 1200 --poll-seconds 15 --extend-seconds 1200 + + # Runs once the tunnel step above ends (its own timeout, cloudflared + # exiting, or a manual cancel), so this captures anything logged in + # response to real traffic during the tunnel's lifetime -- unlike the + # earlier startup-time log dump. + - name: Dump hub logs after tunnel closes + if: always() + env: + KUBECONFIG: ${{ steps.sandbox.outputs.kubeconfig }} + run: kubectl -n pr-preview logs deployment/hub --tail=1000 || true + + # The URL in the deployment box above is dead once the tunnel closes + # -- mark it inactive so the box reflects that instead of still + # showing green with a link that no longer resolves. + - name: Mark GitHub deployment inactive + if: always() + run: | + python -m scripts.preview.github_api mark-inactive \ + --repo "${{ github.repository }}" --token "${{ secrets.GITHUB_TOKEN }}" \ + --deployment-id "${DEPLOYMENT_ID:-}" --description "Preview expired" + + # The comment above still says "Ready" / "Expires in N minutes" once + # the tunnel closes -- it was only ever written once, at deploy time, + # and only keeps the clock ticking, not the + # surrounding wording or status. Edit it again now that the run is + # actually tearing down, so a reader doesn't see a green "Ready" row + # next to a relative time that has flipped to the past. Only do this + # when the first post succeeded (skip if the run failed before ever + # reaching that step, since there is no live-preview comment to + # correct in that case). + - name: Render the expired-preview comment body + id: render_expired + if: always() && steps.comment_preview.outcome == 'success' + run: | + python -m scripts.preview.comment render-expired \ + --expires-at "${{ steps.timestamps.outputs.expires_at }}" \ + --expires-at-iso "${{ steps.timestamps.outputs.expires_at_iso }}" + + - name: Comment that the preview expired + if: always() && steps.comment_preview.outcome == 'success' + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: k8s-preview + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + message: ${{ steps.render_expired.outputs.body }} + + - name: Delete DNS record + if: always() + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + run: | + python -m scripts.preview.cloudflare delete-dns \ + --api-token "$CF_API_TOKEN" --zone-id "${ZONE_ID:-}" \ + --record-id "${DNS_RECORD_ID:-}" --record-id "${KEYCLOAK_DNS_RECORD_ID:-}" + + - name: Delete Cloudflare Tunnel + if: always() + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_TUNNEL_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_TUNNEL_ACCOUNT_ID }} + run: | + python -m scripts.preview.cloudflare delete-tunnel \ + --account-id "$CF_ACCOUNT_ID" --api-token "$CF_API_TOKEN" --tunnel-id "${TUNNEL_ID:-}" + + 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 + deployments: write + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Cancel the in-flight preview run for this PR + run: | + python -m scripts.preview.github_api cancel-in-flight-run \ + --repo "${{ github.repository }}" --token "${{ secrets.GITHUB_TOKEN }}" \ + --workflow-name "K8s Stack Preview" --pr "${{ github.event.pull_request.number }}" + + - name: Mark GitHub deployment inactive + run: | + python -m scripts.preview.github_api mark-latest-inactive \ + --repo "${{ github.repository }}" --token "${{ secrets.GITHUB_TOKEN }}" \ + --environment "pr-${{ github.event.pull_request.number }}-preview" \ + --description "Preview stopped (label removed)" + + - name: Render the stopped-preview comment body + id: render_stopped + run: python -m scripts.preview.comment render-stopped + + - 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: ${{ steps.render_stopped.outputs.body }} diff --git a/config/jupyterhub/02-jhub-apps.py b/config/jupyterhub/02-jhub-apps.py index e559204..f42f485 100644 --- a/config/jupyterhub/02-jhub-apps.py +++ b/config/jupyterhub/02-jhub-apps.py @@ -2,12 +2,38 @@ # ruff: noqa: F821 - `c` is a magic global provided by JupyterHub import os +import shlex from jhub_apps import theme_template_paths, themes from jhub_apps.configuration import install_jhub_apps from kubespawner import KubeSpawner from z2jh import get_config +# jhub-apps runs as a managed-service subprocess inside the SAME pod as +# hub, but z2jh's hub_connect_ip="hub" (needed so KubeSpawner pods on other +# nodes can reach the hub) makes JupyterHub inject JUPYTERHUB_API_URL +# pointing at the `hub` Service (ClusterIP self-reference) for every +# service, same-pod ones included. Routing same-pod traffic through a +# Service depends on the CNI supporting hairpin NAT for a pod reaching its +# own Service -- confirmed to time out (httpcore.ConnectTimeout) on a +# kind/kindnet cluster. +# +# This can't be fixed by precomputing the target URL here in Python: +# os.environ has no JUPYTERHUB_API_URL at config-load time in the hub +# container's own process -- JupyterHub only computes and injects that +# value into a service's environment at spawn time (confirmed live: an +# earlier version of this fix read os.environ.get("JUPYTERHUB_API_URL") +# here and it was always empty, so the rewrite never applied). Instead, +# wrap the service's own command with a shell snippet that rewrites +# $JUPYTERHUB_API_URL to localhost (keeping port/path) at the one point +# where the real value exists: the moment the subprocess itself execs, +# using whatever JupyterHub just put in its environment. +_REWRITE_HUB_API_URL_TO_LOCALHOST = ( + 'export JUPYTERHUB_API_URL="$(printf %s "$JUPYTERHUB_API_URL" | ' + "sed -E 's#^(https?://)[^/:]+#\\1localhost#')\"" +) + + # Configure jhub-apps # bind_url must include the real external hostname so JupyterHub constructs # correct OAuth redirect URLs for internal services like jhub-apps. @@ -104,3 +130,20 @@ if svc.get("name") == "japps": svc.setdefault("environment", {})["JUPYTERHUB_OIDC_CLIENT_SECRET"] = _oidc_secret break + +# Point jhub-apps' own hub-API client at localhost instead of the `hub` +# Service it inherits from z2jh -- see _REWRITE_HUB_API_URL_TO_LOCALHOST's +# comment above. Setting svc["environment"] (like the OIDC secret above) +# does NOT work here: JupyterHub's Spawner.get_env() computes +# env['JUPYTERHUB_API_URL'] = hub_api_url from self.hub.api_url AFTER +# merging self.environment, unconditionally overwriting whatever we set +# there -- confirmed live. +for svc in c.JupyterHub.services: + if svc.get("name") == "japps" and svc.get("command"): + quoted_cmd = " ".join(shlex.quote(part) for part in svc["command"]) + svc["command"] = [ + "sh", + "-c", + f"{_REWRITE_HUB_API_URL_TO_LOCALHOST}; exec {quoted_cmd}", + ] + break diff --git a/scripts/preview/cloudflare.py b/scripts/preview/cloudflare.py new file mode 100644 index 0000000..3e6f4b2 --- /dev/null +++ b/scripts/preview/cloudflare.py @@ -0,0 +1,232 @@ +"""Cloudflare Tunnel + DNS API calls for the k8s preview environment. + +One per-PR named Tunnel (not the anonymous quick-tunnel) so it can sit +behind a Cloudflare Access application and each PR gets its own hostname. + +Usage (each subcommand mirrors one k8s-preview.yaml workflow step): + python -m scripts.preview.cloudflare create-tunnel --account-id ID \\ + --api-token TOKEN --name NAME \\ + --preview-hostname HOST --preview-service URL \\ + --keycloak-hostname HOST --keycloak-service URL + python -m scripts.preview.cloudflare create-dns --api-token TOKEN \\ + --domain DOMAIN --preview-hostname HOST --keycloak-hostname HOST \\ + --target TUNNEL_ID.cfargotunnel.com + python -m scripts.preview.cloudflare delete-dns --api-token TOKEN \\ + --zone-id ID --record-id ID [--record-id ID ...] + python -m scripts.preview.cloudflare delete-tunnel --account-id ID \\ + --api-token TOKEN --tunnel-id ID +""" + +from __future__ import annotations + +import argparse +import base64 +import secrets +import sys + +from . import gha +from .http import HTTPRequestError, request_json + +API_ROOT = "https://api.cloudflare.com/client/v4" + + +class TunnelCreationError(RuntimeError): + pass + + +class ZoneNotFoundError(RuntimeError): + pass + + +def _headers(api_token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {api_token}"} + + +def create_or_reuse_tunnel(account_id: str, api_token: str, tunnel_name: str) -> str: + """Create a named Tunnel, or reuse one already created under this name. + + A GitHub Actions retry reuses the same run id (only run_attempt changes), + so a re-run after an attempt that already created this tunnel (and didn't + get to clean it up) hits a name conflict. Reuse the existing tunnel by + name instead of failing -- it doesn't need the original tunnel secret, + just a fresh token from ``get_tunnel_token``. + """ + tunnel_secret = base64.b64encode(secrets.token_bytes(32)).decode() + try: + result = request_json( + "POST", + f"{API_ROOT}/accounts/{account_id}/cfd_tunnel", + headers=_headers(api_token), + body={"name": tunnel_name, "config_src": "cloudflare", "tunnel_secret": tunnel_secret}, + ) + return result["result"]["id"] + except HTTPRequestError: + existing = request_json( + "GET", + f"{API_ROOT}/accounts/{account_id}/cfd_tunnel?name={tunnel_name}&is_deleted=false", + headers=_headers(api_token), + ) + matches = existing.get("result") or [] + if not matches: + raise TunnelCreationError( + f"tunnel creation failed and no existing tunnel named {tunnel_name!r} was found" + ) from None + return matches[0]["id"] + + +def get_tunnel_token(account_id: str, api_token: str, tunnel_id: str) -> str: + result = request_json( + "GET", + f"{API_ROOT}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token", + headers=_headers(api_token), + ) + return result["result"] + + +def configure_ingress( + account_id: str, api_token: str, tunnel_id: str, hostname_services: list[tuple[str, str]] +) -> None: + """Point each (hostname, service-url) pair at the tunnel, 404 for everything else.""" + ingress = [{"hostname": host, "service": service} for host, service in hostname_services] + ingress.append({"service": "http_status:404"}) + request_json( + "PUT", + f"{API_ROOT}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations", + headers=_headers(api_token), + body={"config": {"ingress": ingress}}, + ) + + +def delete_tunnel(account_id: str, api_token: str, tunnel_id: str) -> None: + request_json( + "DELETE", + f"{API_ROOT}/accounts/{account_id}/cfd_tunnel/{tunnel_id}", + headers=_headers(api_token), + ) + + +def resolve_zone_id(api_token: str, domain: str) -> str: + result = request_json("GET", f"{API_ROOT}/zones?name={domain}", headers=_headers(api_token)) + matches = result.get("result") or [] + if not matches: + raise ZoneNotFoundError(f"could not resolve a zone id for {domain!r}") + return matches[0]["id"] + + +def create_dns_record(api_token: str, zone_id: str, hostname: str, target: str) -> str: + result = request_json( + "POST", + f"{API_ROOT}/zones/{zone_id}/dns_records", + headers=_headers(api_token), + body={"type": "CNAME", "name": hostname, "content": target, "proxied": True}, + ) + return result["result"]["id"] + + +def delete_dns_record(api_token: str, zone_id: str, record_id: str) -> None: + request_json( + "DELETE", + f"{API_ROOT}/zones/{zone_id}/dns_records/{record_id}", + headers=_headers(api_token), + ) + + +def _cmd_create_tunnel(args: argparse.Namespace) -> int: + try: + tunnel_id = create_or_reuse_tunnel(args.account_id, args.api_token, args.name) + token = get_tunnel_token(args.account_id, args.api_token, tunnel_id) + configure_ingress( + args.account_id, args.api_token, tunnel_id, + [(args.preview_hostname, args.preview_service), (args.keycloak_hostname, args.keycloak_service)], + ) + except (TunnelCreationError, HTTPRequestError) as exc: + gha.error(f"Cloudflare Tunnel setup failed: {exc}") + return 1 + gha.mask(token) + gha.write_output("tunnel_id", tunnel_id) + gha.write_env("TUNNEL_ID", tunnel_id) + gha.write_env("TUNNEL_TOKEN", token) + return 0 + + +def _cmd_create_dns(args: argparse.Namespace) -> int: + try: + zone_id = resolve_zone_id(args.api_token, args.domain) + record_id = create_dns_record(args.api_token, zone_id, args.preview_hostname, args.target) + keycloak_record_id = create_dns_record(args.api_token, zone_id, args.keycloak_hostname, args.target) + except (ZoneNotFoundError, HTTPRequestError) as exc: + gha.error(f"DNS record creation failed: {exc}") + return 1 + gha.write_env("ZONE_ID", zone_id) + gha.write_env("DNS_RECORD_ID", record_id) + gha.write_env("KEYCLOAK_DNS_RECORD_ID", keycloak_record_id) + gha.write_output("url", f"https://{args.preview_hostname}") + gha.write_output("keycloak_url", f"https://{args.keycloak_hostname}") + return 0 + + +def _cmd_delete_dns(args: argparse.Namespace) -> int: + """Best-effort: cleanup steps must never fail the job.""" + if not args.zone_id: + return 0 + for record_id in args.record_id: + if not record_id: + continue + try: + delete_dns_record(args.api_token, args.zone_id, record_id) + except HTTPRequestError: + pass + return 0 + + +def _cmd_delete_tunnel(args: argparse.Namespace) -> int: + """Best-effort: cleanup steps must never fail the job.""" + if not args.tunnel_id: + return 0 + try: + delete_tunnel(args.account_id, args.api_token, args.tunnel_id) + except HTTPRequestError: + pass + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="cloudflare") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("create-tunnel") + p.add_argument("--account-id", required=True) + p.add_argument("--api-token", required=True) + p.add_argument("--name", required=True) + p.add_argument("--preview-hostname", required=True) + p.add_argument("--preview-service", required=True) + p.add_argument("--keycloak-hostname", required=True) + p.add_argument("--keycloak-service", required=True) + p.set_defaults(func=_cmd_create_tunnel) + + p = sub.add_parser("create-dns") + p.add_argument("--api-token", required=True) + p.add_argument("--domain", required=True) + p.add_argument("--preview-hostname", required=True) + p.add_argument("--keycloak-hostname", required=True) + p.add_argument("--target", required=True) + p.set_defaults(func=_cmd_create_dns) + + p = sub.add_parser("delete-dns") + p.add_argument("--api-token", required=True) + p.add_argument("--zone-id", required=True) + p.add_argument("--record-id", action="append", default=[]) + p.set_defaults(func=_cmd_delete_dns) + + p = sub.add_parser("delete-tunnel") + p.add_argument("--account-id", required=True) + p.add_argument("--api-token", required=True) + p.add_argument("--tunnel-id", required=True) + p.set_defaults(func=_cmd_delete_tunnel) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/preview/comment.py b/scripts/preview/comment.py new file mode 100644 index 0000000..3b514f2 --- /dev/null +++ b/scripts/preview/comment.py @@ -0,0 +1,122 @@ +"""PR-comment body builders for the k8s preview environment. + +Pure string-building functions -- the actual find-or-edit-in-place +mechanics stay in marocchino/sticky-pull-request-comment (an already-vetted +action doing exactly what it's for); only the message *content* lives here. +Uses GitHub's own web component (used all over its UI for +"3 minutes ago") instead of a static UTC string the reader has to convert +by hand -- confirmed via `gh api /markdown` to survive comment sanitization +unstripped. + +Usage: + python -m scripts.preview.comment render-ready --url URL \\ + --keycloak-url URL --deployed-at STR --deployed-at-iso ISO \\ + --expires-at STR --expires-at-iso ISO [--fork] + python -m scripts.preview.comment render-expired \\ + --expires-at STR --expires-at-iso ISO + python -m scripts.preview.comment render-stopped +""" + +from __future__ import annotations + +import argparse +import sys + +from . import gha + +PROJECT = "nebari-data-science-pack" + + +def render_ready( + url: str, + keycloak_url: str, + deployed_at: str, + deployed_at_iso: str, + expires_at: str, + expires_at_iso: str, + is_fork: bool, +) -> str: + fork_warning = ( + "\n\nāš ļø **This PR is from a fork**: the code running in this preview " + "is not from a trusted maintainer branch." + if is_fork + else "" + ) + return ( + "The latest K8s stack preview for this PR.\n\n" + "| Project | Deployment | Actions | Updated |\n" + "| --- | --- | --- | --- |\n" + f"| `{PROJECT}` | 🟢 [Ready]({url}) | [Preview]({url}) Ā· [Keycloak]({keycloak_url}) | " + f'{deployed_at} |' + f"{fork_warning}\n\n" + f'Expires {expires_at}. ' + "Add the `extend-preview` label any time before then for 20 more minutes, " + "or push a new commit or re-add `deploy-preview` to redeploy from scratch." + ) + + +def render_expired(expires_at: str, expires_at_iso: str) -> str: + return ( + "The K8s stack preview for this PR has expired.\n\n" + "| Project | Deployment | Actions | Updated |\n" + "| --- | --- | --- | --- |\n" + f"| `{PROJECT}` | ⚫ Expired | - | " + f'{expires_at} |\n\n' + "Push a new commit or re-add the `deploy-preview` label to redeploy." + ) + + +def render_stopped() -> str: + return ( + "**K8s stack preview** stopped: the `deploy-preview` label was removed.\n\n" + "Add it again to redeploy." + ) + + +def _cmd_render_ready(args: argparse.Namespace) -> int: + body = render_ready( + args.url, args.keycloak_url, args.deployed_at, args.deployed_at_iso, + args.expires_at, args.expires_at_iso, args.fork, + ) + gha.write_output("body", body) + return 0 + + +def _cmd_render_expired(args: argparse.Namespace) -> int: + gha.write_output("body", render_expired(args.expires_at, args.expires_at_iso)) + return 0 + + +def _cmd_render_stopped(args: argparse.Namespace) -> int: + gha.write_output("body", render_stopped()) + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="comment") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("render-ready") + p.add_argument("--url", required=True) + p.add_argument("--keycloak-url", required=True) + p.add_argument("--deployed-at", required=True) + p.add_argument("--deployed-at-iso", required=True) + p.add_argument("--expires-at", required=True) + p.add_argument("--expires-at-iso", required=True) + p.add_argument("--fork", action="store_true") + p.set_defaults(func=_cmd_render_ready) + + p = sub.add_parser("render-expired") + p.add_argument("--expires-at", required=True) + p.add_argument("--expires-at-iso", required=True) + p.set_defaults(func=_cmd_render_expired) + + p = sub.add_parser("render-stopped") + p.set_defaults(func=_cmd_render_stopped) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/preview/gha.py b/scripts/preview/gha.py new file mode 100644 index 0000000..2a4ad24 --- /dev/null +++ b/scripts/preview/gha.py @@ -0,0 +1,36 @@ +"""Helpers for writing GitHub Actions step outputs/env vars/masks/errors. + +``path`` defaults to the real ``$GITHUB_OUTPUT``/``$GITHUB_ENV`` files GitHub +Actions provides at runtime; tests pass an explicit path instead. +""" + +from __future__ import annotations + +import os +import uuid + + +def _write_kv(path: str, name: str, value: str) -> None: + if "\n" in value: + delimiter = f"ghadelim_{uuid.uuid4().hex}" + block = f"{name}<<{delimiter}\n{value}\n{delimiter}\n" + else: + block = f"{name}={value}\n" + with open(path, "a", encoding="utf-8") as f: + f.write(block) + + +def write_output(name: str, value: str, path: str | None = None) -> None: + _write_kv(path or os.environ["GITHUB_OUTPUT"], name, value) + + +def write_env(name: str, value: str, path: str | None = None) -> None: + _write_kv(path or os.environ["GITHUB_ENV"], name, value) + + +def mask(value: str) -> None: + print(f"::add-mask::{value}") + + +def error(message: str) -> None: + print(f"::error::{message}") diff --git a/scripts/preview/github_api.py b/scripts/preview/github_api.py new file mode 100644 index 0000000..e510879 --- /dev/null +++ b/scripts/preview/github_api.py @@ -0,0 +1,284 @@ +"""GitHub REST API calls shared by the preview-deploy scripts. + +All calls go through ``http.request_json`` (stdlib ``urllib.request``, no new +dependency) rather than shelling out to the ``gh`` CLI, so the actual request +being made is a plain, testable function call. + +Usage: + python -m scripts.preview.github_api ensure-label-exists --repo R \\ + --token T --name NAME --color COLOR --description DESC + python -m scripts.preview.github_api create-and-activate --repo R \\ + --token T --ref SHA --environment ENV --task TASK --description DESC \\ + --environment-url URL --log-url URL --status-description DESC + python -m scripts.preview.github_api mark-inactive --repo R --token T \\ + --deployment-id ID --description DESC + python -m scripts.preview.github_api mark-latest-inactive --repo R \\ + --token T --environment ENV --description DESC + python -m scripts.preview.github_api cancel-in-flight-run --repo R \\ + --token T --workflow-name NAME --pr N +""" + +from __future__ import annotations + +import argparse +import sys + +from . import gha +from .http import HTTPRequestError, request_json + +API_ROOT = "https://api.github.com" + + +def _headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + +# --- labels ------------------------------------------------------------- + + +def list_labels(repo: str, pr_number: int, token: str) -> list[str]: + """Return the names of every label currently on the PR.""" + result = request_json( + "GET", f"{API_ROOT}/repos/{repo}/issues/{pr_number}/labels", headers=_headers(token) + ) + return [item["name"] for item in result] + + +def delete_label(repo: str, pr_number: int, label: str, token: str) -> None: + """Remove ``label`` from the PR. A no-op if it's already gone.""" + try: + request_json( + "DELETE", + f"{API_ROOT}/repos/{repo}/issues/{pr_number}/labels/{label}", + headers=_headers(token), + ) + except HTTPRequestError as exc: + if exc.status != 404: + raise + + +def ensure_label_exists(repo: str, name: str, color: str, description: str, token: str) -> None: + """Create the repo label ``name`` if it doesn't already exist.""" + try: + request_json( + "POST", + f"{API_ROOT}/repos/{repo}/labels", + headers=_headers(token), + body={"name": name, "color": color, "description": description}, + ) + except HTTPRequestError as exc: + if exc.status != 422: + raise + + +# --- deployments ---------------------------------------------------------- + + +def create_deployment( + repo: str, + ref: str, + environment: str, + token: str, + task: str = "deploy:preview", + description: str = "", +) -> int: + """Create a GitHub Deployment and return its id.""" + result = request_json( + "POST", + f"{API_ROOT}/repos/{repo}/deployments", + headers=_headers(token), + body={ + "ref": ref, + "environment": environment, + "task": task, + "auto_merge": False, + "transient_environment": True, + "production_environment": False, + "required_contexts": [], + "description": description, + }, + ) + return result["id"] + + +def set_deployment_status( + repo: str, + deployment_id: int, + state: str, + token: str, + environment_url: str | None = None, + log_url: str | None = None, + description: str | None = None, +) -> None: + """Post a new status onto a deployment. Optional fields are omitted, not sent empty.""" + body: dict[str, str] = {"state": state} + if environment_url is not None: + body["environment_url"] = environment_url + if log_url is not None: + body["log_url"] = log_url + if description is not None: + body["description"] = description + request_json( + "POST", + f"{API_ROOT}/repos/{repo}/deployments/{deployment_id}/statuses", + headers=_headers(token), + body=body, + ) + + +def mark_deployment_inactive(repo: str, deployment_id: int, token: str, description: str = "") -> None: + set_deployment_status(repo, deployment_id, "inactive", token, description=description) + + +def find_latest_deployment_id(repo: str, environment: str, token: str) -> int | None: + """Return the most recent deployment id for ``environment``, or None.""" + result = request_json( + "GET", + f"{API_ROOT}/repos/{repo}/deployments?environment={environment}&per_page=1", + headers=_headers(token), + ) + return result[0]["id"] if result else None + + +# --- comments --------------------------------------------------------------- + + +def find_comment_id(repo: str, pr_number: int, marker: str, token: str) -> int | None: + """Return the id of the PR comment containing ``marker``, or None.""" + comments = request_json( + "GET", f"{API_ROOT}/repos/{repo}/issues/{pr_number}/comments?per_page=100", headers=_headers(token) + ) + for c in comments: + if marker in c.get("body", ""): + return c["id"] + return None + + +def update_comment(repo: str, comment_id: int, body: str, token: str) -> None: + request_json( + "PATCH", + f"{API_ROOT}/repos/{repo}/issues/comments/{comment_id}", + headers=_headers(token), + body={"body": body}, + ) + + +# --- workflow runs ---------------------------------------------------------- + + +def cancel_in_flight_run(repo: str, workflow_name: str, pr_number: int, token: str) -> int | None: + """Cancel the in-progress run of ``workflow_name`` for this PR, if any. + + Returns the cancelled run's id, or None if no matching run was running. + """ + result = request_json( + "GET", + f"{API_ROOT}/repos/{repo}/actions/runs?event=pull_request&status=in_progress", + headers=_headers(token), + ) + for run in result.get("workflow_runs", []): + if run["name"] != workflow_name: + continue + if any(pr["number"] == pr_number for pr in run.get("pull_requests", [])): + request_json( + "POST", + f"{API_ROOT}/repos/{repo}/actions/runs/{run['id']}/cancel", + headers=_headers(token), + ) + return run["id"] + return None + + +def _cmd_ensure_label_exists(args: argparse.Namespace) -> int: + ensure_label_exists(args.repo, args.name, args.color, args.description, args.token) + return 0 + + +def _cmd_create_and_activate(args: argparse.Namespace) -> int: + deployment_id = create_deployment( + args.repo, ref=args.ref, environment=args.environment, token=args.token, + task=args.task, description=args.description, + ) + gha.write_env("DEPLOYMENT_ID", str(deployment_id)) + set_deployment_status( + args.repo, deployment_id, "success", args.token, + environment_url=args.environment_url, log_url=args.log_url, + description=args.status_description, + ) + return 0 + + +def _cmd_mark_inactive(args: argparse.Namespace) -> int: + if not args.deployment_id: + return 0 + mark_deployment_inactive(args.repo, int(args.deployment_id), args.token, description=args.description) + return 0 + + +def _cmd_mark_latest_inactive(args: argparse.Namespace) -> int: + deployment_id = find_latest_deployment_id(args.repo, args.environment, args.token) + if deployment_id is None: + return 0 + mark_deployment_inactive(args.repo, deployment_id, args.token, description=args.description) + return 0 + + +def _cmd_cancel_in_flight_run(args: argparse.Namespace) -> int: + cancel_in_flight_run(args.repo, args.workflow_name, args.pr, args.token) + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="github_api") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("ensure-label-exists") + p.add_argument("--repo", required=True) + p.add_argument("--token", required=True) + p.add_argument("--name", required=True) + p.add_argument("--color", required=True) + p.add_argument("--description", required=True) + p.set_defaults(func=_cmd_ensure_label_exists) + + p = sub.add_parser("create-and-activate") + p.add_argument("--repo", required=True) + p.add_argument("--token", required=True) + p.add_argument("--ref", required=True) + p.add_argument("--environment", required=True) + p.add_argument("--task", required=True) + p.add_argument("--description", required=True) + p.add_argument("--environment-url", required=True) + p.add_argument("--log-url", required=True) + p.add_argument("--status-description", required=True) + p.set_defaults(func=_cmd_create_and_activate) + + p = sub.add_parser("mark-inactive") + p.add_argument("--repo", required=True) + p.add_argument("--token", required=True) + p.add_argument("--deployment-id", required=True) + p.add_argument("--description", required=True) + p.set_defaults(func=_cmd_mark_inactive) + + p = sub.add_parser("mark-latest-inactive") + p.add_argument("--repo", required=True) + p.add_argument("--token", required=True) + p.add_argument("--environment", required=True) + p.add_argument("--description", required=True) + p.set_defaults(func=_cmd_mark_latest_inactive) + + p = sub.add_parser("cancel-in-flight-run") + p.add_argument("--repo", required=True) + p.add_argument("--token", required=True) + p.add_argument("--workflow-name", required=True) + p.add_argument("--pr", required=True, type=int) + p.set_defaults(func=_cmd_cancel_in_flight_run) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/preview/http.py b/scripts/preview/http.py new file mode 100644 index 0000000..23aa05f --- /dev/null +++ b/scripts/preview/http.py @@ -0,0 +1,58 @@ +"""Shared JSON HTTP helper for scripts/preview/*.py. + +Thin wrapper over ``urllib.request`` (stdlib, no new dependency -- matches +config/jupyterhub/01-spawner.py's existing convention for this same kind of +Keycloak/GitHub/Cloudflare-style REST call). Raises HTTPRequestError with the +response body on a non-2xx status, so callers get an actual error message +instead of urllib's bare HTTPError. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + + +class HTTPRequestError(RuntimeError): + """Raised when a JSON HTTP request returns a non-2xx status.""" + + def __init__(self, method: str, url: str, status: int, body: str): + self.status = status + self.body = body + super().__init__(f"{method} {url} -> HTTP {status}: {body}") + + +def request_json( + method: str, + url: str, + headers: dict[str, str] | None = None, + body: Any = None, + timeout: float = 15, +) -> Any: + """Send an HTTP request, returning the parsed JSON response body. + + ``body``, if a ``str``, is sent as-is (the caller sets its own + Content-Type, e.g. for a form-urlencoded Keycloak token request); any + other value is JSON-encoded with an application/json Content-Type. + Returns ``{}`` for an empty (e.g. 204) response body. + """ + data = None + req_headers = dict(headers or {}) + if isinstance(body, str): + data = body.encode("utf-8") + elif body is not None: + data = json.dumps(body).encode("utf-8") + req_headers.setdefault("Content-Type", "application/json") + + request = urllib.request.Request(url, data=data, headers=req_headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as resp: + raw = resp.read() + except urllib.error.HTTPError as exc: + raise HTTPRequestError(method, url, exc.code, exc.read().decode("utf-8", errors="replace")) from exc + + if not raw: + return {} + return json.loads(raw) diff --git a/scripts/preview/k8s_wait.py b/scripts/preview/k8s_wait.py new file mode 100644 index 0000000..e77cf42 --- /dev/null +++ b/scripts/preview/k8s_wait.py @@ -0,0 +1,132 @@ +"""kubectl readiness retry loops for the k8s preview environment. + +Usage: + python -m scripts.preview.k8s_wait wait-for-secret-key --namespace NS \\ + --secret NAME --key KEY [--timeout-s 180] [--poll-interval-s 5] + python -m scripts.preview.k8s_wait restart-until-ready --namespace NS \\ + --deployment NAME [--rollout-timeout-s 90] [--max-attempts 5] +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import time +from collections.abc import Callable + +from . import gha + + +def wait_for_secret_key( + get_value: Callable[[], str], + timeout_s: int = 180, + poll_interval_s: int = 5, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> bool: + """Poll ``get_value()`` until it returns a non-empty string, or timeout. + + The operator creates the Secret with client-id/client-secret on its + first reconcile pass, then patches in the issuer-url key on a later + pass -- checking the Secret merely EXISTS isn't enough, a hub restart + right after its first appearance still crashes on the not-yet-populated + key. + """ + deadline = clock() + timeout_s + while clock() < deadline: + if get_value(): + return True + sleep(poll_interval_s) + return False + + +def restart_until_ready( + restart: Callable[[], None], + check_status: Callable[[], bool], + max_attempts: int = 5, +) -> tuple[bool, int]: + """Restart, then check readiness; repeat until ready or ``max_attempts``. + + A single restart isn't reliable here even when the API server confirms + the Secret is fully populated: kubelet's own Secret volume cache + (node-local, ~1min TTL) can still hand a freshly-restarted pod the + pre-population snapshot it fetched for the pod's first, crash-looped + attempt. Retrying gives the kubelet cache time to expire between + attempts. + + Returns (became_ready, attempts_used). + """ + for attempt in range(1, max_attempts + 1): + restart() + if check_status(): + return True, attempt + return False, max_attempts + + +def _cmd_wait_for_secret_key(args: argparse.Namespace) -> int: + def get_value() -> str: + result = subprocess.run( + ["kubectl", "-n", args.namespace, "get", "secret", args.secret, + "-o", f"jsonpath={{.data.{args.key}}}"], + text=True, capture_output=True, check=False, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + ok = wait_for_secret_key(get_value, timeout_s=args.timeout_s, poll_interval_s=args.poll_interval_s) + if not ok: + gha.error( + f"operator never populated {args.key!r} on secret {args.secret!r} " + f"within {args.timeout_s}s" + ) + subprocess.run(["kubectl", "-n", args.namespace, "get", "nebariapp", "-o", "yaml"], check=False) + return 1 + return 0 + + +def _cmd_restart_until_ready(args: argparse.Namespace) -> int: + target = f"deployment/{args.deployment}" + + def restart() -> None: + subprocess.run(["kubectl", "-n", args.namespace, "rollout", "restart", target], check=False) + + def check_status() -> bool: + result = subprocess.run([ + "kubectl", "-n", args.namespace, "rollout", "status", target, + f"--timeout={args.rollout_timeout_s}s", + ], check=False) + return result.returncode == 0 + + ok, attempts = restart_until_ready(restart, check_status, max_attempts=args.max_attempts) + if ok: + print(f"{args.deployment} ready on attempt {attempts}") + return 0 + gha.error(f"{args.deployment} never became ready after {args.max_attempts} restart attempts") + return 1 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="k8s_wait") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("wait-for-secret-key") + p.add_argument("--namespace", required=True) + p.add_argument("--secret", required=True) + p.add_argument("--key", required=True) + p.add_argument("--timeout-s", type=int, default=180) + p.add_argument("--poll-interval-s", type=int, default=5) + p.set_defaults(func=_cmd_wait_for_secret_key) + + p = sub.add_parser("restart-until-ready") + p.add_argument("--namespace", required=True) + p.add_argument("--deployment", required=True) + p.add_argument("--rollout-timeout-s", type=int, default=90) + p.add_argument("--max-attempts", type=int, default=5) + p.set_defaults(func=_cmd_restart_until_ready) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/preview/keycloak.py b/scripts/preview/keycloak.py new file mode 100644 index 0000000..bb4d6ec --- /dev/null +++ b/scripts/preview/keycloak.py @@ -0,0 +1,90 @@ +"""Keycloak admin API calls for the k8s preview environment's test user. + +Cloudflare Access is the real security boundary for this preview (only +allow-listed accounts reach the tunnel at all), so a simple, known password +for the Keycloak-side login is fine -- reviewers don't need to hunt for real +credentials on a throwaway cluster. + +Usage: + python -m scripts.preview.keycloak create-reviewer-user --base-url URL \\ + --realm REALM --admin-password PASSWORD +""" + +from __future__ import annotations + +import argparse +import sys +from urllib.parse import urlencode + +from . import gha +from .http import request_json + + +class KeycloakAuthError(RuntimeError): + pass + + +def get_admin_token(base_url: str, admin_password: str) -> str: + body = urlencode( + { + "grant_type": "password", + "client_id": "admin-cli", + "username": "admin", + "password": admin_password, + } + ) + result = request_json( + "POST", + f"{base_url}/realms/master/protocol/openid-connect/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + token = result.get("access_token") + if not token: + raise KeycloakAuthError(f"no access_token in Keycloak admin token response: {result}") + return token + + +def create_reviewer_user(base_url: str, realm: str, admin_token: str) -> None: + request_json( + "POST", + f"{base_url}/admin/realms/{realm}/users", + headers={"Authorization": f"Bearer {admin_token}"}, + body={ + "username": "reviewer", + "enabled": True, + "email": "reviewer@example.com", + "emailVerified": True, + "firstName": "Preview", + "lastName": "Reviewer", + "credentials": [{"type": "password", "value": "admin", "temporary": False}], + }, + ) + + +def _cmd_create_reviewer_user(args: argparse.Namespace) -> int: + try: + token = get_admin_token(args.base_url, args.admin_password) + create_reviewer_user(args.base_url, args.realm, token) + except Exception as exc: # noqa: BLE001 - report and fail the step either way + gha.error(f"Failed to create the Keycloak reviewer user: {exc}") + return 1 + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="keycloak") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("create-reviewer-user") + p.add_argument("--base-url", required=True) + p.add_argument("--realm", required=True) + p.add_argument("--admin-password", required=True) + p.set_defaults(func=_cmd_create_reviewer_user) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/preview/keycloak_gitops.py b/scripts/preview/keycloak_gitops.py new file mode 100644 index 0000000..28bdb16 --- /dev/null +++ b/scripts/preview/keycloak_gitops.py @@ -0,0 +1,162 @@ +"""Repoint Keycloak's own hostname via NIC's local GitOps repo. + +Keycloak's own KC_HOSTNAME (codecentric/keycloakx, via NIC's +values/keycloak/base.yaml) is fixed to NIC's internal keycloak.nebari.local +by default -- every self-referencing URL Keycloak renders (login form +action, issuer, redirects) is absolute and uses that value regardless of +the incoming Host header. + +A direct `kubectl set env` patch onto the live StatefulSet/Deployment DOES +apply immediately but gets silently reverted: both Keycloak and +nebari-operator are ArgoCD Applications with selfHeal: true, continuously +reconciled against NIC's auto-created local GitOps repo +(~/.nic/gitops/). Editing the GitOps repo itself and forcing +a hard refresh lets selfHeal work for this change instead of against it. + +Usage: + python -m scripts.preview.keycloak_gitops patch \\ + --gitops-dir DIR --kc-public-url URL +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from collections.abc import Callable +from pathlib import Path + +from . import gha + +OLD_HOSTNAME_URL = "https://keycloak.nebari.local" +GITOPS_FILES = ("values/keycloak/base.yaml", "manifests/nebari-operator/deployment-patch.yaml") + + +class OperatorNotFoundError(RuntimeError): + pass + + +def rewrite_hostname(text: str, old_url: str, new_url: str) -> str: + """Replace every literal occurrence of ``old_url`` with ``new_url``.""" + return text.replace(old_url, new_url) + + +def find_operator_deployment(deployments_json: dict) -> tuple[str, str]: + """Return (namespace, name) of the deployment whose name contains "operator".""" + for item in deployments_json.get("items", []): + name = item["metadata"]["name"] + if re.search("operator", name): + return item["metadata"]["namespace"], name + raise OperatorNotFoundError("no deployment with 'operator' in its name was found") + + +def extract_env_value(env: list[dict], var_name: str) -> str: + """Return the value of ``var_name`` in a container env list, or "" if absent.""" + for entry in env: + if entry.get("name") == var_name: + return entry.get("value", "") + return "" + + +def wait_until_both_match( + get_values: Callable[[], tuple[str, str]], + expected: str, + timeout_s: int = 120, + poll_interval_s: int = 5, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> tuple[bool, tuple[str, str]]: + """Poll ``get_values()`` until both returned values equal ``expected``. + + `rollout status` alone can return instantly if ArgoCD hasn't actually + applied the refreshed spec yet (nothing new requested from kubectl's + point of view) -- poll for the live values to actually change before + trusting rollout status to mean anything. + """ + deadline = clock() + timeout_s + values = ("", "") + while clock() < deadline: + values = get_values() + if values[0] == expected and values[1] == expected: + return True, values + sleep(poll_interval_s) + return False, values + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, check=True, text=True, capture_output=True) + + +def _cmd_patch(args: argparse.Namespace) -> int: + gitops_dir = Path(args.gitops_dir) + + for relative_path in GITOPS_FILES: + path = gitops_dir / relative_path + original = path.read_text() + print(f"--- {relative_path}: {original.count(OLD_HOSTNAME_URL)} occurrence(s) of {OLD_HOSTNAME_URL} ---") + path.write_text(rewrite_hostname(original, OLD_HOSTNAME_URL, args.kc_public_url)) + + _run([ + "git", "-C", str(gitops_dir), + "-c", "user.email=ci@example.com", "-c", "user.name=k8s-preview-ci", + "commit", "-am", "Point Keycloak hostname at the public preview tunnel route", + ]) + + _run([ + "kubectl", "-n", "argocd", "annotate", + "application/keycloak", "application/nebari-operator", + "argocd.argoproj.io/refresh=hard", "--overwrite", + ]) + + deployments = json.loads(_run(["kubectl", "get", "deploy", "-A", "-o", "json"]).stdout) + try: + operator_namespace, operator_name = find_operator_deployment(deployments) + except OperatorNotFoundError as exc: + gha.error(str(exc)) + return 1 + + def get_values() -> tuple[str, str]: + kc_env = json.loads(_run([ + "kubectl", "-n", "keycloak", "get", "statefulset", "keycloak-keycloakx", + "-o", "jsonpath={.spec.template.spec.containers[0].env}", + ]).stdout or "[]") + op_env = json.loads(_run([ + "kubectl", "-n", operator_namespace, "get", "deploy", operator_name, + "-o", "jsonpath={.spec.template.spec.containers[0].env}", + ]).stdout or "[]") + return ( + extract_env_value(kc_env, "KC_HOSTNAME"), + extract_env_value(op_env, "KEYCLOAK_EXTERNAL_URL"), + ) + + matched, (kc_live, op_live) = wait_until_both_match(get_values, args.kc_public_url) + if not matched: + gha.error( + "ArgoCD never applied the GitOps hostname change within 2m " + f"(keycloak={kc_live}, operator={op_live})" + ) + return 1 + + _run(["kubectl", "-n", "keycloak", "rollout", "status", "statefulset/keycloak-keycloakx", "--timeout=180s"]) + _run(["kubectl", "-n", operator_namespace, "rollout", "status", f"deployment/{operator_name}", "--timeout=180s"]) + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="keycloak_gitops") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("patch") + p.add_argument("--gitops-dir", required=True) + p.add_argument("--kc-public-url", required=True) + p.set_defaults(func=_cmd_patch) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/preview/tunnel.py b/scripts/preview/tunnel.py new file mode 100644 index 0000000..2f742f7 --- /dev/null +++ b/scripts/preview/tunnel.py @@ -0,0 +1,173 @@ +"""Runs cloudflared and lets an extend-preview label push its deadline back. + +Bounded to `initial_seconds` by default so the live preview doesn't sit +open (and burn CI minutes) indefinitely. Polls for the `extend_label` on +the PR between checks; each occurrence resets the deadline to +now + extend_seconds (not a cumulative add onto whatever's left) and is +consumed by removing the label, so it can be reused any number of times +before expiry. Still ultimately bounded by the calling job's own +timeout-minutes regardless of how many times it's extended. + +The PR comment's "Expires" text is otherwise only ever rendered once, at +deploy time (see comment.py + the "Comment preview link on PR" workflow +step) -- extending the tunnel's internal deadline alone does nothing to +it, confirmed live: the comment kept showing the original 20-minute mark +after two real extends. On every successful extend, this module now +re-renders the ready comment with the new expiry and PATCHes it directly +via the GitHub API (the sticky-comment action only runs at fixed workflow +steps, not from inside this loop, so it can't be reused here). That side +effect is best-effort: any failure updating the comment is logged and +swallowed, never allowed to take down the tunnel itself. + +Usage: + python -m scripts.preview.tunnel run --cloudflared PATH --token TOKEN \\ + --repo OWNER/REPO --pr N --github-token TOKEN \\ + --url URL --keycloak-url URL \\ + --deployed-at STR --deployed-at-iso ISO [--fork] \\ + [--initial-seconds 1200] [--poll-seconds 15] [--extend-seconds 1200] \\ + [--extend-label extend-preview] +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import time +from collections.abc import Callable + +from .comment import render_ready +from .github_api import delete_label, find_comment_id, list_labels, update_comment + +STICKY_MARKER = "" + + +def next_deadline(now: float, current_deadline: float, label_present: bool, extend_seconds: int) -> float: + """What should the deadline be this tick? + + A reset to now + extend_seconds when the label is present, not a + cumulative add onto whatever's left, so an extend always means + "extend_seconds more from right now." + """ + return now + extend_seconds if label_present else current_deadline + + +def should_stop(alive: bool, now: float, deadline: float) -> bool: + return (not alive) or now >= deadline + + +def format_deadline(deadline: float) -> tuple[str, str]: + """Render a deadline (seconds since epoch) as (human, ISO) strings.""" + human = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(deadline)) + iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(deadline)) + return human, iso + + +def run( + cloudflared_path: str, + tunnel_token: str, + repo: str, + pr_number: int, + github_token: str, + url: str, + keycloak_url: str, + deployed_at: str, + deployed_at_iso: str, + is_fork: bool = False, + initial_seconds: int = 1200, + poll_seconds: int = 15, + extend_seconds: int = 1200, + extend_label: str = "extend-preview", + popen: Callable[..., subprocess.Popen] = subprocess.Popen, + clock: Callable[[], float] = time.monotonic, + wall_clock: Callable[[], float] = time.time, + sleep: Callable[[float], None] = time.sleep, + list_labels_fn: Callable[[str, int, str], list[str]] = list_labels, + delete_label_fn: Callable[[str, int, str, str], None] = delete_label, + find_comment_id_fn: Callable[[str, int, str, str], int | None] = find_comment_id, + update_comment_fn: Callable[[str, int, str, str], None] = update_comment, +) -> int: + """Run cloudflared until its deadline, or until it exits on its own. + + Returns cloudflared's real exit code if it exited on its own (a + genuine crash), or 0 if we closed it ourselves (deadline reached). + """ + proc = popen([cloudflared_path, "tunnel", "--no-autoupdate", "run", "--token", tunnel_token]) + deadline = clock() + initial_seconds + + while True: + alive = proc.poll() is None + now = clock() + if should_stop(alive, now, deadline): + if not alive: + return proc.returncode + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return 0 + + if extend_label in list_labels_fn(repo, pr_number, github_token): + deadline = next_deadline(now, deadline, True, extend_seconds) + delete_label_fn(repo, pr_number, extend_label, github_token) + # `deadline` lives in `clock`'s namespace (time.monotonic() by + # default), which has no relationship to the real calendar -- + # feeding it straight into format_deadline() produced garbage + # like "1970-01-01" (confirmed live). Convert the remaining + # duration into a real timestamp via `wall_clock` instead. + seconds_remaining = deadline - now + expires_at, expires_at_iso = format_deadline(wall_clock() + seconds_remaining) + print(f"{extend_label} seen -- new deadline: {expires_at}") + try: + comment_id = find_comment_id_fn(repo, pr_number, STICKY_MARKER, github_token) + if comment_id is not None: + body = ( + render_ready(url, keycloak_url, deployed_at, deployed_at_iso, expires_at, expires_at_iso, is_fork) + + "\n" + STICKY_MARKER + ) + update_comment_fn(repo, comment_id, body, github_token) + except Exception as exc: # noqa: BLE001 - the tunnel staying up matters more than the comment being exact + print(f"warning: failed to update the PR comment after extend: {exc}") + + sleep(poll_seconds) + + +def _cmd_run(args: argparse.Namespace) -> int: + return run( + args.cloudflared, args.token, args.repo, args.pr, args.github_token, + args.url, args.keycloak_url, args.deployed_at, args.deployed_at_iso, + is_fork=args.fork, + initial_seconds=args.initial_seconds, poll_seconds=args.poll_seconds, + extend_seconds=args.extend_seconds, extend_label=args.extend_label, + ) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="tunnel") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("run") + p.add_argument("--cloudflared", required=True) + p.add_argument("--token", required=True) + p.add_argument("--repo", required=True) + p.add_argument("--pr", required=True, type=int) + p.add_argument("--github-token", required=True) + p.add_argument("--url", required=True) + p.add_argument("--keycloak-url", required=True) + p.add_argument("--deployed-at", required=True) + p.add_argument("--deployed-at-iso", required=True) + p.add_argument("--fork", action="store_true") + p.add_argument("--initial-seconds", type=int, default=1200) + p.add_argument("--poll-seconds", type=int, default=15) + p.add_argument("--extend-seconds", type=int, default=1200) + p.add_argument("--extend-label", default="extend-preview") + p.set_defaults(func=_cmd_run) + + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/unit/test_jhub_apps_backend_url.py b/tests/unit/test_jhub_apps_backend_url.py new file mode 100644 index 0000000..25802dd --- /dev/null +++ b/tests/unit/test_jhub_apps_backend_url.py @@ -0,0 +1,135 @@ +"""jhub-apps' own hub-API base URL wiring in 02-jhub-apps.py. + +jhub-apps runs as a managed-service subprocess inside the SAME pod as +hub, but z2jh injects JUPYTERHUB_API_URL pointing at the `hub` Service +(ClusterIP self-reference). Routing same-pod traffic through a Service +depends on the CNI supporting hairpin NAT for a pod reaching its own +Service via that Service's ClusterIP -- confirmed to time out +(httpcore.ConnectTimeout) on a kind/kindnet cluster. Rewriting the host +to localhost (same port/path) is always reliable for same-pod traffic +and doesn't depend on hairpin NAT support. + +The rewrite can't be precomputed in Python at config-load time: the hub +container's own os.environ has no JUPYTERHUB_API_URL there (JupyterHub +only computes and injects that value into a service's own environment +at spawn time) -- confirmed live, an earlier version of this fix that +read os.environ.get("JUPYTERHUB_API_URL") here always got "", so the +rewrite never applied and jhub-apps kept timing out against the `hub` +Service. So the command is wrapped with a shell snippet that rewrites +$JUPYTERHUB_API_URL to localhost at the moment the subprocess execs, +using whatever JupyterHub has actually put in its environment by then. + +02-jhub-apps.py isn't independently importable: it needs `jhub_apps` +and `z2jh` (real chart-image dependencies, not installed in the unit +test venv) at module load time for things unrelated to this fix. Stub +both minimally so the real file executes end-to-end and this test +exercises the actual production code path, not a reimplementation of +it elsewhere. +""" + +from __future__ import annotations + +import subprocess +import sys +import types + +from conftest import FakeConfig, load_config_module + +ORIGINAL_COMMAND = [ + "python", + "-m", + "uvicorn", + "jhub_apps.service.app:app", + "--port=10202", + "--host=0.0.0.0", + "--workers=1", +] + + +def _install_stub_dependencies(monkeypatch): + """Stub jhub_apps + z2jh just enough for 02-jhub-apps.py to load.""" + jhub_apps_mod = types.ModuleType("jhub_apps") + jhub_apps_mod.theme_template_paths = [] + jhub_apps_mod.themes = types.SimpleNamespace(DEFAULT_THEME={}) + + def _fake_install_jhub_apps(c, spawner_to_subclass=None): + c.JupyterHub.services = [ + { + "name": "japps", + "oauth_client_id": "service-japps", + "command": list(ORIGINAL_COMMAND), + } + ] + c.JupyterHub.load_roles = [{"name": "user", "scopes": []}] + return c + + configuration_mod = types.ModuleType("jhub_apps.configuration") + configuration_mod.install_jhub_apps = _fake_install_jhub_apps + + def _fake_get_config(key, default=None): + return default + + z2jh_mod = types.ModuleType("z2jh") + z2jh_mod.get_config = _fake_get_config + + monkeypatch.setitem(sys.modules, "jhub_apps", jhub_apps_mod) + monkeypatch.setitem(sys.modules, "jhub_apps.configuration", configuration_mod) + monkeypatch.setitem(sys.modules, "z2jh", z2jh_mod) + + +def _load(monkeypatch): + _install_stub_dependencies(monkeypatch) + c = FakeConfig() + mod = load_config_module("02-jhub-apps.py", inject_c=c) + return c, mod + + +def _japps_service(c): + return next(svc for svc in c.JupyterHub.services if svc.get("name") == "japps") + + +def _run_wrapped_command(command, jupyterhub_api_url): + """Actually run the wrapped shell command's rewrite in a real shell. + + The rewrite lives in a shell snippet, not Python, so the only honest + test is exec'ing it in a real /bin/sh with a fake JUPYTERHUB_API_URL + and observing the rewritten value -- a unit test of the Python string + construction alone couldn't catch a shell syntax error or a wrong sed + pattern. Splits the rewrite off the trailing `exec ` + (which would actually try to launch uvicorn) and prints the result + instead of exec'ing it. + """ + assert command[:2] == ["sh", "-c"] + rewrite = command[2].split("; exec ", 1)[0] + result = subprocess.run( + ["sh", "-c", f'{rewrite}; printf %s "$JUPYTERHUB_API_URL"'], + env={"JUPYTERHUB_API_URL": jupyterhub_api_url, "PATH": "/usr/bin:/bin"}, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +def test_japps_command_is_wrapped_in_a_shell_rewrite(monkeypatch): + c, _ = _load(monkeypatch) + japps = _japps_service(c) + assert japps["command"][:2] == ["sh", "-c"] + # The original argv is still there, just appended after the rewrite. + assert "exec python -m uvicorn jhub_apps.service.app:app" in japps["command"][2] + + +def test_rewrite_replaces_host_with_localhost_keeping_port_and_path(monkeypatch): + c, _ = _load(monkeypatch) + japps = _japps_service(c) + out = _run_wrapped_command(japps["command"], "http://hub:8081/hub/api") + assert out == "http://localhost:8081/hub/api" + + +def test_rewrite_handles_a_url_with_no_explicit_port(monkeypatch): + c, _ = _load(monkeypatch) + japps = _japps_service(c) + out = _run_wrapped_command(japps["command"], "http://hub/hub/api") + assert out == "http://localhost/hub/api" diff --git a/tests/unit/test_preview_cloudflare.py b/tests/unit/test_preview_cloudflare.py new file mode 100644 index 0000000..56037e1 --- /dev/null +++ b/tests/unit/test_preview_cloudflare.py @@ -0,0 +1,237 @@ +"""Tests for scripts/preview/cloudflare.py's Cloudflare Tunnel + DNS helpers.""" + +from __future__ import annotations + +import pytest + +from scripts.preview import cloudflare +from scripts.preview.http import HTTPRequestError + +ACCOUNT_ID = "acct-1" +API_TOKEN = "cf-token" + + +def _capture(monkeypatch): + calls = [] + + def fake_request_json(method, url, headers=None, body=None, timeout=15): + calls.append({"method": method, "url": url, "headers": headers, "body": body}) + return fake_request_json.next_result + + fake_request_json.next_result = {} + monkeypatch.setattr("scripts.preview.cloudflare.request_json", fake_request_json) + return calls, fake_request_json + + +# --- tunnel create/reuse ----------------------------------------------------- + + +def test_create_or_reuse_tunnel_returns_id_on_successful_create(monkeypatch): + calls, fake = _capture(monkeypatch) + fake.next_result = {"result": {"id": "tunnel-abc"}} + + tunnel_id = cloudflare.create_or_reuse_tunnel(ACCOUNT_ID, API_TOKEN, "pr-205-run-1") + + assert tunnel_id == "tunnel-abc" + assert calls[0]["method"] == "POST" + assert calls[0]["body"]["name"] == "pr-205-run-1" + assert calls[0]["body"]["config_src"] == "cloudflare" + assert "tunnel_secret" in calls[0]["body"] + + +def test_create_or_reuse_tunnel_reuses_existing_on_name_conflict(monkeypatch): + lookup_result = {"result": [{"id": "existing-tunnel"}]} + + def fake_request_json(method, url, headers=None, body=None, timeout=15): + if method == "POST": + raise HTTPRequestError(method, url, 409, "tunnel with name already exists") + return lookup_result + + monkeypatch.setattr("scripts.preview.cloudflare.request_json", fake_request_json) + + tunnel_id = cloudflare.create_or_reuse_tunnel(ACCOUNT_ID, API_TOKEN, "pr-205-run-1") + + assert tunnel_id == "existing-tunnel" + + +def test_create_or_reuse_tunnel_raises_when_conflict_and_no_existing_found(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + if method == "POST": + raise HTTPRequestError(method, url, 409, "conflict") + return {"result": []} + + monkeypatch.setattr("scripts.preview.cloudflare.request_json", fake_request_json) + + with pytest.raises(cloudflare.TunnelCreationError): + cloudflare.create_or_reuse_tunnel(ACCOUNT_ID, API_TOKEN, "pr-205-run-1") + + +# --- tunnel token / ingress / delete ------------------------------------------ + + +def test_get_tunnel_token_returns_result_string(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = {"result": "the-token"} + + assert cloudflare.get_tunnel_token(ACCOUNT_ID, API_TOKEN, "tunnel-abc") == "the-token" + + +def test_configure_ingress_sends_hostname_rules_with_catchall(monkeypatch): + calls, _ = _capture(monkeypatch) + + cloudflare.configure_ingress( + ACCOUNT_ID, API_TOKEN, "tunnel-abc", + [("pr-205.example.com", "http://localhost:8000"), ("kc-pr-205.example.com", "http://localhost:8001")], + ) + + ingress = calls[0]["body"]["config"]["ingress"] + assert ingress[0] == {"hostname": "pr-205.example.com", "service": "http://localhost:8000"} + assert ingress[1] == {"hostname": "kc-pr-205.example.com", "service": "http://localhost:8001"} + assert ingress[-1] == {"service": "http_status:404"} + + +def test_delete_tunnel_calls_delete_endpoint(monkeypatch): + calls, _ = _capture(monkeypatch) + + cloudflare.delete_tunnel(ACCOUNT_ID, API_TOKEN, "tunnel-abc") + + assert calls[0]["method"] == "DELETE" + assert calls[0]["url"].endswith("/accounts/acct-1/cfd_tunnel/tunnel-abc") + + +# --- DNS ----------------------------------------------------------------------- + + +def test_resolve_zone_id_returns_first_match(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = {"result": [{"id": "zone-1"}]} + + assert cloudflare.resolve_zone_id(API_TOKEN, "example.com") == "zone-1" + + +def test_resolve_zone_id_raises_when_not_found(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = {"result": []} + + with pytest.raises(cloudflare.ZoneNotFoundError): + cloudflare.resolve_zone_id(API_TOKEN, "example.com") + + +def test_create_dns_record_returns_record_id(monkeypatch): + calls, fake = _capture(monkeypatch) + fake.next_result = {"result": {"id": "record-1"}} + + record_id = cloudflare.create_dns_record(API_TOKEN, "zone-1", "pr-205.example.com", "tunnel-abc.cfargotunnel.com") + + assert record_id == "record-1" + assert calls[0]["body"] == { + "type": "CNAME", + "name": "pr-205.example.com", + "content": "tunnel-abc.cfargotunnel.com", + "proxied": True, + } + + +def test_delete_dns_record_calls_delete_endpoint(monkeypatch): + calls, _ = _capture(monkeypatch) + + cloudflare.delete_dns_record(API_TOKEN, "zone-1", "record-1") + + assert calls[0]["method"] == "DELETE" + assert calls[0]["url"].endswith("/zones/zone-1/dns_records/record-1") + + +# --- CLI (main) --------------------------------------------------------------- + + +def test_main_create_tunnel_writes_outputs_and_masks_secrets(monkeypatch, tmp_path): + monkeypatch.setattr(cloudflare, "create_or_reuse_tunnel", lambda *a, **k: "tunnel-abc") + monkeypatch.setattr(cloudflare, "get_tunnel_token", lambda *a, **k: "tok-xyz") + configured = {} + monkeypatch.setattr( + cloudflare, "configure_ingress", + lambda account_id, api_token, tunnel_id, hosts: configured.update(hosts=hosts), + ) + out_file = tmp_path / "output" + env_file = tmp_path / "env" + out_file.write_text("") + env_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + monkeypatch.setenv("GITHUB_ENV", str(env_file)) + + rc = cloudflare.main([ + "cloudflare", "create-tunnel", + "--account-id", "acct-1", "--api-token", "tok", "--name", "pr-205-1", + "--preview-hostname", "pr-205.example.com", "--preview-service", "http://localhost:8000", + "--keycloak-hostname", "kc-pr-205.example.com", "--keycloak-service", "http://localhost:8001", + ]) + + assert rc == 0 + assert "tunnel_id=tunnel-abc" in out_file.read_text() + assert "TUNNEL_ID=tunnel-abc" in env_file.read_text() + assert "TUNNEL_TOKEN=tok-xyz" in env_file.read_text() + assert configured["hosts"] == [ + ("pr-205.example.com", "http://localhost:8000"), + ("kc-pr-205.example.com", "http://localhost:8001"), + ] + + +def test_main_create_tunnel_exits_1_on_failure(monkeypatch, capsys): + def boom(*a, **k): + raise cloudflare.TunnelCreationError("no dice") + + monkeypatch.setattr(cloudflare, "create_or_reuse_tunnel", boom) + + rc = cloudflare.main([ + "cloudflare", "create-tunnel", + "--account-id", "acct-1", "--api-token", "tok", "--name", "pr-205-1", + "--preview-hostname", "pr-205.example.com", "--preview-service", "http://localhost:8000", + "--keycloak-hostname", "kc-pr-205.example.com", "--keycloak-service", "http://localhost:8001", + ]) + + assert rc == 1 + assert "::error::" in capsys.readouterr().out + + +def test_main_delete_dns_noops_when_zone_id_missing(monkeypatch): + called = [] + monkeypatch.setattr(cloudflare, "delete_dns_record", lambda *a, **k: called.append(a)) + + rc = cloudflare.main(["cloudflare", "delete-dns", "--api-token", "tok", "--zone-id", "", "--record-id", "r1"]) + + assert rc == 0 + assert called == [] + + +def test_main_delete_dns_deletes_each_nonempty_record_id(monkeypatch): + called = [] + monkeypatch.setattr(cloudflare, "delete_dns_record", lambda api_token, zone_id, record_id: called.append(record_id)) + + rc = cloudflare.main([ + "cloudflare", "delete-dns", "--api-token", "tok", "--zone-id", "zone-1", + "--record-id", "r1", "--record-id", "", + ]) + + assert rc == 0 + assert called == ["r1"] + + +def test_main_delete_dns_swallows_api_errors(monkeypatch): + def boom(*a, **k): + raise HTTPRequestError("DELETE", "https://x", 500, "boom") + + monkeypatch.setattr(cloudflare, "delete_dns_record", boom) + + rc = cloudflare.main(["cloudflare", "delete-dns", "--api-token", "tok", "--zone-id", "zone-1", "--record-id", "r1"]) + + assert rc == 0 # cleanup steps are best-effort, never fail the job + + +def test_main_delete_tunnel_noops_when_tunnel_id_missing(monkeypatch): + called = [] + monkeypatch.setattr(cloudflare, "delete_tunnel", lambda *a, **k: called.append(a)) + + rc = cloudflare.main(["cloudflare", "delete-tunnel", "--account-id", "acct-1", "--api-token", "tok", "--tunnel-id", ""]) + + assert rc == 0 + assert called == [] diff --git a/tests/unit/test_preview_comment.py b/tests/unit/test_preview_comment.py new file mode 100644 index 0000000..d4d1077 --- /dev/null +++ b/tests/unit/test_preview_comment.py @@ -0,0 +1,122 @@ +"""Tests for scripts/preview/comment.py's PR-comment body builders. + +These strings were manually verified against GitHub's real markdown +renderer (`gh api /markdown`) earlier -- this module just moves the exact +same content into a testable function instead of a workflow `message:` +block, so these tests pin the content, not re-verify GitHub's rendering. +""" + +from __future__ import annotations + +from scripts.preview import comment + +URL = "https://pr-205-data-science-pack.openteams.app" +KC_URL = "https://keycloak-pr-205-data-science-pack.openteams.app" +DEPLOYED_AT = "2026-09-01 12:21 UTC" +DEPLOYED_AT_ISO = "2026-09-01T12:21:18Z" +EXPIRES_AT = "2026-09-01 12:41 UTC" +EXPIRES_AT_ISO = "2026-09-01T12:41:18Z" + + +def test_render_ready_contains_the_status_row_and_links(): + body = comment.render_ready(URL, KC_URL, DEPLOYED_AT, DEPLOYED_AT_ISO, EXPIRES_AT, EXPIRES_AT_ISO, is_fork=False) + + assert "| Project | Deployment | Actions | Updated |" in body + assert f"🟢 [Ready]({URL})" in body + assert f"[Preview]({URL})" in body + assert f"[Keycloak]({KC_URL})" in body + assert f'{DEPLOYED_AT}' in body + assert f'{EXPIRES_AT}' in body + assert "extend-preview" in body + assert "deploy-preview" in body + + +def test_render_ready_omits_fork_warning_when_not_a_fork(): + body = comment.render_ready(URL, KC_URL, DEPLOYED_AT, DEPLOYED_AT_ISO, EXPIRES_AT, EXPIRES_AT_ISO, is_fork=False) + + assert "fork" not in body.lower() + + +def test_render_ready_includes_fork_warning_when_a_fork(): + body = comment.render_ready(URL, KC_URL, DEPLOYED_AT, DEPLOYED_AT_ISO, EXPIRES_AT, EXPIRES_AT_ISO, is_fork=True) + + assert "This PR is from a fork" in body + assert "not from a trusted maintainer branch" in body + + +def test_render_ready_has_no_em_or_en_dashes(): + body = comment.render_ready(URL, KC_URL, DEPLOYED_AT, DEPLOYED_AT_ISO, EXPIRES_AT, EXPIRES_AT_ISO, is_fork=True) + + assert "—" not in body + assert "–" not in body + + +def test_render_expired_shows_expired_status_and_no_live_links(): + body = comment.render_expired(EXPIRES_AT, EXPIRES_AT_ISO) + + assert "has expired" in body + assert "⚫ Expired" in body + assert URL not in body + assert f'{EXPIRES_AT}' in body + + +def test_render_stopped_mentions_the_label(): + body = comment.render_stopped() + + assert "stopped" in body + assert "deploy-preview" in body + + +def test_main_render_ready_writes_body_output(monkeypatch, tmp_path): + out_file = tmp_path / "output" + out_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + + rc = comment.main([ + "comment", "render-ready", + "--url", URL, "--keycloak-url", KC_URL, + "--deployed-at", DEPLOYED_AT, "--deployed-at-iso", DEPLOYED_AT_ISO, + "--expires-at", EXPIRES_AT, "--expires-at-iso", EXPIRES_AT_ISO, + ]) + + assert rc == 0 + content = out_file.read_text() + assert content.startswith("body<<") + assert "🟢 [Ready]" in content + + +def test_main_render_ready_with_fork_flag_includes_warning(monkeypatch, tmp_path): + out_file = tmp_path / "output" + out_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + + comment.main([ + "comment", "render-ready", "--fork", + "--url", URL, "--keycloak-url", KC_URL, + "--deployed-at", DEPLOYED_AT, "--deployed-at-iso", DEPLOYED_AT_ISO, + "--expires-at", EXPIRES_AT, "--expires-at-iso", EXPIRES_AT_ISO, + ]) + + assert "This PR is from a fork" in out_file.read_text() + + +def test_main_render_expired_writes_body_output(monkeypatch, tmp_path): + out_file = tmp_path / "output" + out_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + + rc = comment.main(["comment", "render-expired", "--expires-at", EXPIRES_AT, "--expires-at-iso", EXPIRES_AT_ISO]) + + assert rc == 0 + assert "has expired" in out_file.read_text() + + +def test_main_render_stopped_writes_body_output(monkeypatch, tmp_path): + out_file = tmp_path / "output" + out_file.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) + + rc = comment.main(["comment", "render-stopped"]) + + assert rc == 0 + assert "stopped" in out_file.read_text() diff --git a/tests/unit/test_preview_gha.py b/tests/unit/test_preview_gha.py new file mode 100644 index 0000000..367a805 --- /dev/null +++ b/tests/unit/test_preview_gha.py @@ -0,0 +1,59 @@ +"""Tests for scripts/preview/gha.py's GitHub Actions I/O helpers.""" + +from __future__ import annotations + +from scripts.preview import gha + + +def test_write_output_appends_single_line_key_value(tmp_path, capsys): + out_file = tmp_path / "output" + out_file.write_text("") + + gha.write_output("url", "https://example.com", path=str(out_file)) + + assert out_file.read_text() == "url=https://example.com\n" + + +def test_write_output_appends_to_existing_content(tmp_path): + out_file = tmp_path / "output" + out_file.write_text("existing=1\n") + + gha.write_output("url", "https://example.com", path=str(out_file)) + + assert out_file.read_text() == "existing=1\nurl=https://example.com\n" + + +def test_write_output_multiline_value_uses_delimiter_block(tmp_path): + out_file = tmp_path / "output" + out_file.write_text("") + + gha.write_output("body", "line one\nline two", path=str(out_file)) + + content = out_file.read_text() + lines = content.splitlines() + assert lines[0].startswith("body<<") + delim = lines[0].split("<<", 1)[1] + assert lines[1] == "line one" + assert lines[2] == "line two" + assert lines[3] == delim + + +def test_write_env_appends_single_line_key_value(tmp_path): + env_file = tmp_path / "env" + env_file.write_text("") + + gha.write_env("TUNNEL_ID", "abc-123", path=str(env_file)) + + assert env_file.read_text() == "TUNNEL_ID=abc-123\n" + + +def test_mask_prints_add_mask_command(capsys): + gha.mask("super-secret-token") + + assert capsys.readouterr().out == "::add-mask::super-secret-token\n" + + +def test_error_prints_error_command(capsys): + gha.error("tunnel creation failed") + + assert capsys.readouterr().out == "::error::tunnel creation failed\n" diff --git a/tests/unit/test_preview_github_api.py b/tests/unit/test_preview_github_api.py new file mode 100644 index 0000000..f9fc743 --- /dev/null +++ b/tests/unit/test_preview_github_api.py @@ -0,0 +1,317 @@ +"""Tests for scripts/preview/github_api.py's GitHub REST helpers.""" + +from __future__ import annotations + +import pytest + +from scripts.preview import github_api +from scripts.preview.http import HTTPRequestError + +REPO = "nebari-dev/data-science-pack" +TOKEN = "gh-token" + + +def _capture(monkeypatch): + calls = [] + + def fake_request_json(method, url, headers=None, body=None, timeout=15): + calls.append({"method": method, "url": url, "headers": headers, "body": body}) + return fake_request_json.next_result + + fake_request_json.next_result = {} + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + return calls, fake_request_json + + +# --- labels ----------------------------------------------------------------- + + +def test_list_labels_returns_names(monkeypatch): + calls, fake = _capture(monkeypatch) + fake.next_result = [{"name": "deploy-preview"}, {"name": "extend-preview"}] + + result = github_api.list_labels(REPO, 205, TOKEN) + + assert result == ["deploy-preview", "extend-preview"] + assert calls[0]["url"] == f"https://api.github.com/repos/{REPO}/issues/205/labels" + assert calls[0]["method"] == "GET" + assert calls[0]["headers"]["Authorization"] == f"Bearer {TOKEN}" + + +def test_delete_label_calls_delete_endpoint(monkeypatch): + calls, _ = _capture(monkeypatch) + + github_api.delete_label(REPO, 205, "extend-preview", TOKEN) + + assert calls[0]["method"] == "DELETE" + assert calls[0]["url"] == f"https://api.github.com/repos/{REPO}/issues/205/labels/extend-preview" + + +def test_delete_label_swallows_404_already_removed(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + raise HTTPRequestError(method, url, 404, "Not Found") + + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + + github_api.delete_label(REPO, 205, "extend-preview", TOKEN) # must not raise + + +def test_delete_label_reraises_other_errors(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + raise HTTPRequestError(method, url, 403, "Forbidden") + + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + + with pytest.raises(HTTPRequestError): + github_api.delete_label(REPO, 205, "extend-preview", TOKEN) + + +def test_ensure_label_exists_swallows_422_already_exists(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + raise HTTPRequestError(method, url, 422, "already_exists") + + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + + github_api.ensure_label_exists(REPO, "extend-preview", "BFD4F2", "desc", TOKEN) + + +def test_ensure_label_exists_reraises_other_errors(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + raise HTTPRequestError(method, url, 500, "boom") + + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + + with pytest.raises(HTTPRequestError): + github_api.ensure_label_exists(REPO, "extend-preview", "BFD4F2", "desc", TOKEN) + + +# --- deployments -------------------------------------------------------------- + + +def test_create_deployment_posts_expected_payload_and_returns_id(monkeypatch): + calls, fake = _capture(monkeypatch) + fake.next_result = {"id": 6199751384} + + deployment_id = github_api.create_deployment( + REPO, ref="abc123", environment="pr-205-preview", token=TOKEN, + task="deploy:preview", description="K8s stack preview", + ) + + assert deployment_id == 6199751384 + body = calls[0]["body"] + assert body["ref"] == "abc123" + assert body["environment"] == "pr-205-preview" + assert body["task"] == "deploy:preview" + assert body["auto_merge"] is False + assert body["transient_environment"] is True + assert body["production_environment"] is False + assert body["required_contexts"] == [] + + +def test_set_deployment_status_omits_unset_optional_fields(monkeypatch): + calls, _ = _capture(monkeypatch) + + github_api.set_deployment_status(REPO, 42, "success", TOKEN, environment_url="https://x") + + body = calls[0]["body"] + assert body["state"] == "success" + assert body["environment_url"] == "https://x" + assert "log_url" not in body + assert "description" not in body + + +def test_mark_deployment_inactive_sets_inactive_state(monkeypatch): + calls, _ = _capture(monkeypatch) + + github_api.mark_deployment_inactive(REPO, 42, TOKEN, description="Preview expired") + + assert calls[0]["url"] == f"https://api.github.com/repos/{REPO}/deployments/42/statuses" + assert calls[0]["body"]["state"] == "inactive" + assert calls[0]["body"]["description"] == "Preview expired" + + +def test_find_latest_deployment_id_returns_first_result(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = [{"id": 111}, {"id": 222}] + + assert github_api.find_latest_deployment_id(REPO, "pr-205-preview", TOKEN) == 111 + + +def test_find_latest_deployment_id_returns_none_when_empty(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = [] + + assert github_api.find_latest_deployment_id(REPO, "pr-205-preview", TOKEN) is None + + +# --- runs --------------------------------------------------------------------- + + +def test_cancel_in_flight_run_finds_and_cancels_matching_run(monkeypatch): + calls = [] + + def fake_request_json(method, url, headers=None, body=None, timeout=15): + calls.append({"method": method, "url": url, "headers": headers, "body": body}) + if "workflow_runs" in url or url.endswith("status=in_progress"): + return { + "workflow_runs": [ + {"id": 1, "name": "Other Workflow", "pull_requests": [{"number": 205}]}, + {"id": 2, "name": "K8s Stack Preview", "pull_requests": [{"number": 999}]}, + {"id": 3, "name": "K8s Stack Preview", "pull_requests": [{"number": 205}]}, + ] + } + return {} + + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + + cancelled = github_api.cancel_in_flight_run(REPO, "K8s Stack Preview", 205, TOKEN) + + assert cancelled == 3 + cancel_calls = [c for c in calls if c["url"].endswith("/runs/3/cancel")] + assert len(cancel_calls) == 1 + assert cancel_calls[0]["method"] == "POST" + + +def test_cancel_in_flight_run_returns_none_when_no_match(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + return {"workflow_runs": []} + + monkeypatch.setattr("scripts.preview.github_api.request_json", fake_request_json) + + assert github_api.cancel_in_flight_run(REPO, "K8s Stack Preview", 205, TOKEN) is None + + +# --- CLI (main) --------------------------------------------------------------- + + +def test_main_ensure_label_exists_calls_through(monkeypatch): + called = [] + monkeypatch.setattr(github_api, "ensure_label_exists", lambda *a: called.append(a)) + + rc = github_api.main([ + "github_api", "ensure-label-exists", "--repo", REPO, "--token", TOKEN, + "--name", "extend-preview", "--color", "BFD4F2", "--description", "desc", + ]) + + assert rc == 0 + assert called == [(REPO, "extend-preview", "BFD4F2", "desc", TOKEN)] + + +def test_main_create_and_activate_writes_deployment_id_env(monkeypatch, tmp_path): + monkeypatch.setattr(github_api, "create_deployment", lambda *a, **k: 999) + set_calls = [] + monkeypatch.setattr(github_api, "set_deployment_status", lambda *a, **k: set_calls.append((a, k))) + env_file = tmp_path / "env" + env_file.write_text("") + monkeypatch.setenv("GITHUB_ENV", str(env_file)) + + rc = github_api.main([ + "github_api", "create-and-activate", "--repo", REPO, "--token", TOKEN, + "--ref", "abc123", "--environment", "pr-205-preview", "--task", "deploy:preview", + "--description", "K8s stack preview", "--environment-url", "https://x", "--log-url", "https://y", + "--status-description", "Live for 20 minutes", + ]) + + assert rc == 0 + assert "DEPLOYMENT_ID=999" in env_file.read_text() + assert set_calls[0][0] == (REPO, 999, "success", TOKEN) + assert set_calls[0][1] == {"environment_url": "https://x", "log_url": "https://y", "description": "Live for 20 minutes"} + + +def test_main_mark_inactive_noops_when_deployment_id_missing(monkeypatch): + called = [] + monkeypatch.setattr(github_api, "mark_deployment_inactive", lambda *a, **k: called.append(a)) + + rc = github_api.main([ + "github_api", "mark-inactive", "--repo", REPO, "--token", TOKEN, + "--deployment-id", "", "--description", "unused", + ]) + + assert rc == 0 + assert called == [] + + +def test_main_mark_inactive_calls_through_when_present(monkeypatch): + called = [] + monkeypatch.setattr(github_api, "mark_deployment_inactive", lambda *a, **k: called.append((a, k))) + + rc = github_api.main([ + "github_api", "mark-inactive", "--repo", REPO, "--token", TOKEN, + "--deployment-id", "999", "--description", "Preview expired", + ]) + + assert rc == 0 + assert called == [((REPO, 999, TOKEN), {"description": "Preview expired"})] + + +def test_main_mark_latest_inactive_noops_when_none_found(monkeypatch): + monkeypatch.setattr(github_api, "find_latest_deployment_id", lambda *a: None) + called = [] + monkeypatch.setattr(github_api, "mark_deployment_inactive", lambda *a, **k: called.append(a)) + + rc = github_api.main([ + "github_api", "mark-latest-inactive", "--repo", REPO, "--token", TOKEN, + "--environment", "pr-205-preview", "--description", "stopped", + ]) + + assert rc == 0 + assert called == [] + + +def test_main_mark_latest_inactive_marks_when_found(monkeypatch): + monkeypatch.setattr(github_api, "find_latest_deployment_id", lambda *a: 42) + called = [] + monkeypatch.setattr(github_api, "mark_deployment_inactive", lambda *a, **k: called.append((a, k))) + + rc = github_api.main([ + "github_api", "mark-latest-inactive", "--repo", REPO, "--token", TOKEN, + "--environment", "pr-205-preview", "--description", "stopped", + ]) + + assert rc == 0 + assert called == [((REPO, 42, TOKEN), {"description": "stopped"})] + + +def test_main_cancel_in_flight_run_calls_through(monkeypatch): + called = [] + monkeypatch.setattr(github_api, "cancel_in_flight_run", lambda *a: called.append(a) or 3) + + rc = github_api.main([ + "github_api", "cancel-in-flight-run", "--repo", REPO, "--token", TOKEN, + "--workflow-name", "K8s Stack Preview", "--pr", "205", + ]) + + assert rc == 0 + assert called == [(REPO, "K8s Stack Preview", 205, TOKEN)] + + +# --- comments ----------------------------------------------------------------- + + +def test_find_comment_id_returns_id_of_comment_containing_marker(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = [ + {"id": 111, "body": "unrelated comment"}, + {"id": 222, "body": "some text\n"}, + ] + + found = github_api.find_comment_id(REPO, 205, "", TOKEN) + + assert found == 222 + + +def test_find_comment_id_returns_none_when_not_found(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = [{"id": 111, "body": "unrelated comment"}] + + assert github_api.find_comment_id(REPO, 205, "", TOKEN) is None + + +def test_update_comment_patches_the_comment_body(monkeypatch): + calls, _ = _capture(monkeypatch) + + github_api.update_comment(REPO, 222, "new body", TOKEN) + + assert calls[0]["method"] == "PATCH" + assert calls[0]["url"] == f"https://api.github.com/repos/{REPO}/issues/comments/222" + assert calls[0]["body"] == {"body": "new body"} diff --git a/tests/unit/test_preview_http.py b/tests/unit/test_preview_http.py new file mode 100644 index 0000000..8f09e02 --- /dev/null +++ b/tests/unit/test_preview_http.py @@ -0,0 +1,134 @@ +"""Tests for scripts/preview/http.py's shared JSON HTTP helper.""" + +from __future__ import annotations + +import json +import urllib.error + +import pytest + +from scripts.preview.http import HTTPRequestError, request_json + + +class _FakeResponse: + def __init__(self, body: bytes): + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def test_get_returns_parsed_json_body(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["timeout"] = timeout + return _FakeResponse(b'{"id": 42}') + + monkeypatch.setattr("scripts.preview.http.urllib.request.urlopen", fake_urlopen) + + result = request_json("GET", "https://api.example.com/thing", timeout=5) + + assert result == {"id": 42} + assert captured["url"] == "https://api.example.com/thing" + assert captured["method"] == "GET" + assert captured["timeout"] == 5 + + +def test_post_sends_json_encoded_body_with_content_type(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["data"] = request.data + captured["content_type"] = request.get_header("Content-type") + return _FakeResponse(b"{}") + + monkeypatch.setattr("scripts.preview.http.urllib.request.urlopen", fake_urlopen) + + request_json("POST", "https://api.example.com/thing", body={"name": "pr-205"}) + + assert json.loads(captured["data"]) == {"name": "pr-205"} + assert captured["content_type"] == "application/json" + + +def test_string_body_is_sent_as_is_not_json_encoded(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["data"] = request.data + captured["content_type"] = request.get_header("Content-type") + return _FakeResponse(b"{}") + + monkeypatch.setattr("scripts.preview.http.urllib.request.urlopen", fake_urlopen) + + request_json( + "POST", + "https://api.example.com/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body="grant_type=password&username=admin", + ) + + assert captured["data"] == b"grant_type=password&username=admin" + # Content-Type came from the caller's headers, not auto-set to json. + assert captured["content_type"] == "application/x-www-form-urlencoded" + + +def test_custom_headers_are_forwarded(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["auth"] = request.get_header("Authorization") + return _FakeResponse(b"{}") + + monkeypatch.setattr("scripts.preview.http.urllib.request.urlopen", fake_urlopen) + + request_json( + "GET", + "https://api.example.com/thing", + headers={"Authorization": "Bearer tok"}, + ) + + assert captured["auth"] == "Bearer tok" + + +def test_empty_response_body_returns_empty_dict(monkeypatch): + monkeypatch.setattr( + "scripts.preview.http.urllib.request.urlopen", + lambda request, timeout: _FakeResponse(b""), + ) + + assert request_json("DELETE", "https://api.example.com/thing") == {} + + +def test_http_error_raises_with_status_and_body(monkeypatch): + def fake_urlopen(request, timeout): + raise urllib.error.HTTPError( + request.full_url, 409, "Conflict", hdrs=None, fp=None + ) + + monkeypatch.setattr("scripts.preview.http.urllib.request.urlopen", fake_urlopen) + # HTTPError.read() needs a real body stream; patch it directly on the + # instance urlopen raises rather than fighting urllib's fp plumbing. + real_error = urllib.error.HTTPError( + "https://api.example.com/thing", 409, "Conflict", hdrs=None, fp=None + ) + monkeypatch.setattr(real_error, "read", lambda: b"name already exists") + monkeypatch.setattr( + "scripts.preview.http.urllib.request.urlopen", + lambda request, timeout: (_ for _ in ()).throw(real_error), + ) + + with pytest.raises(HTTPRequestError) as exc_info: + request_json("POST", "https://api.example.com/thing", body={"name": "x"}) + + assert exc_info.value.status == 409 + assert "name already exists" in exc_info.value.body + assert "name already exists" in str(exc_info.value) diff --git a/tests/unit/test_preview_k8s_wait.py b/tests/unit/test_preview_k8s_wait.py new file mode 100644 index 0000000..735c142 --- /dev/null +++ b/tests/unit/test_preview_k8s_wait.py @@ -0,0 +1,79 @@ +"""Tests for scripts/preview/k8s_wait.py's kubectl readiness retry loops.""" + +from __future__ import annotations + +from scripts.preview import k8s_wait + + +def _fake_time(): + state = {"t": 0.0} + return (lambda: state["t"]), (lambda s: state.__setitem__("t", state["t"] + s)) + + +# --- wait_for_secret_key ------------------------------------------------- + + +def test_wait_for_secret_key_returns_true_once_value_nonempty(): + calls = [] + + def get_value(): + calls.append(1) + return "" if len(calls) == 1 else "aGVsbG8=" + + clock, sleep = _fake_time() + + ok = k8s_wait.wait_for_secret_key(get_value, timeout_s=300, poll_interval_s=5, clock=clock, sleep=sleep) + + assert ok is True + assert len(calls) == 2 + + +def test_wait_for_secret_key_returns_false_after_timeout(): + clock, sleep = _fake_time() + + ok = k8s_wait.wait_for_secret_key(lambda: "", timeout_s=10, poll_interval_s=5, clock=clock, sleep=sleep) + + assert ok is False + + +# --- restart_until_ready --------------------------------------------------- + + +def test_restart_until_ready_stops_on_first_successful_attempt(): + restart_calls = [] + status_calls = [] + + ok, attempts = k8s_wait.restart_until_ready( + restart=lambda: restart_calls.append(1), + check_status=lambda: (status_calls.append(1), True)[1], + max_attempts=5, + ) + + assert ok is True + assert attempts == 1 + assert len(restart_calls) == 1 + assert len(status_calls) == 1 + + +def test_restart_until_ready_retries_until_success(): + status_results = iter([False, False, True]) + + ok, attempts = k8s_wait.restart_until_ready( + restart=lambda: None, + check_status=lambda: next(status_results), + max_attempts=5, + ) + + assert ok is True + assert attempts == 3 + + +def test_restart_until_ready_gives_up_after_max_attempts(): + ok, attempts = k8s_wait.restart_until_ready( + restart=lambda: None, + check_status=lambda: False, + max_attempts=3, + ) + + assert ok is False + assert attempts == 3 diff --git a/tests/unit/test_preview_keycloak.py b/tests/unit/test_preview_keycloak.py new file mode 100644 index 0000000..a9a7e71 --- /dev/null +++ b/tests/unit/test_preview_keycloak.py @@ -0,0 +1,82 @@ +"""Tests for scripts/preview/keycloak.py's admin API helpers.""" + +from __future__ import annotations + +import pytest + +from scripts.preview import keycloak +from scripts.preview.http import HTTPRequestError + +BASE_URL = "http://localhost:8001" + + +def _capture(monkeypatch): + calls = [] + + def fake_request_json(method, url, headers=None, body=None, timeout=15): + calls.append({"method": method, "url": url, "headers": headers, "body": body}) + return fake_request_json.next_result + + fake_request_json.next_result = {} + monkeypatch.setattr("scripts.preview.keycloak.request_json", fake_request_json) + return calls, fake_request_json + + +def test_get_admin_token_sends_password_grant_as_form_body(monkeypatch): + calls, fake = _capture(monkeypatch) + fake.next_result = {"access_token": "the-token"} + + token = keycloak.get_admin_token(BASE_URL, "adminpw") + + assert token == "the-token" + assert calls[0]["url"] == f"{BASE_URL}/realms/master/protocol/openid-connect/token" + assert calls[0]["headers"]["Content-Type"] == "application/x-www-form-urlencoded" + body = calls[0]["body"] + assert "grant_type=password" in body + assert "client_id=admin-cli" in body + assert "username=admin" in body + assert "password=adminpw" in body + + +def test_get_admin_token_url_encodes_special_characters_in_password(monkeypatch): + calls, fake = _capture(monkeypatch) + fake.next_result = {"access_token": "tok"} + + keycloak.get_admin_token(BASE_URL, "p@ss w/ord&x") + + assert "password=p%40ss+w%2Ford%26x" in calls[0]["body"] + + +def test_get_admin_token_raises_when_response_has_no_access_token(monkeypatch): + _, fake = _capture(monkeypatch) + fake.next_result = {"error": "invalid_grant"} + + with pytest.raises(keycloak.KeycloakAuthError): + keycloak.get_admin_token(BASE_URL, "wrong") + + +def test_get_admin_token_propagates_http_errors(monkeypatch): + def fake_request_json(method, url, headers=None, body=None, timeout=15): + raise HTTPRequestError(method, url, 401, "unauthorized") + + monkeypatch.setattr("scripts.preview.keycloak.request_json", fake_request_json) + + with pytest.raises(HTTPRequestError): + keycloak.get_admin_token(BASE_URL, "wrong") + + +def test_create_reviewer_user_posts_expected_payload(monkeypatch): + calls, _ = _capture(monkeypatch) + + keycloak.create_reviewer_user(BASE_URL, "nebari", "admin-token") + + assert calls[0]["url"] == f"{BASE_URL}/admin/realms/nebari/users" + assert calls[0]["headers"]["Authorization"] == "Bearer admin-token" + body = calls[0]["body"] + assert body["username"] == "reviewer" + assert body["enabled"] is True + assert body["email"] == "reviewer@example.com" + assert body["emailVerified"] is True + assert body["firstName"] == "Preview" + assert body["lastName"] == "Reviewer" + assert body["credentials"] == [{"type": "password", "value": "admin", "temporary": False}] diff --git a/tests/unit/test_preview_keycloak_gitops.py b/tests/unit/test_preview_keycloak_gitops.py new file mode 100644 index 0000000..bf6ec39 --- /dev/null +++ b/tests/unit/test_preview_keycloak_gitops.py @@ -0,0 +1,120 @@ +"""Tests for scripts/preview/keycloak_gitops.py. + +Covers the GitOps hostname rewrite for Keycloak's own KC_HOSTNAME -- see +that module's docstring for why this can't just be a live kubectl patch +(ArgoCD selfHeal reverts it). +""" + +from __future__ import annotations + +from scripts.preview import keycloak_gitops as kg + +# --- rewrite_hostname (pure) -------------------------------------------------- + + +def test_rewrite_hostname_replaces_every_occurrence(): + text = ( + "issuerUrl: https://keycloak.nebari.local/realms/nebari\n" + "redirectUri: https://keycloak.nebari.local/callback\n" + ) + + result = kg.rewrite_hostname(text, "https://keycloak.nebari.local", "https://kc-pr-205.example.com") + + assert "keycloak.nebari.local" not in result + assert result.count("https://kc-pr-205.example.com") == 2 + + +def test_rewrite_hostname_leaves_unrelated_text_untouched(): + text = "some_other_key: value\n" + + result = kg.rewrite_hostname(text, "https://keycloak.nebari.local", "https://kc-pr-205.example.com") + + assert result == text + + +# --- find_operator_deployment (pure) ------------------------------------------ + + +def test_find_operator_deployment_matches_name_containing_operator(): + deployments = { + "items": [ + {"metadata": {"namespace": "kube-system", "name": "coredns"}}, + {"metadata": {"namespace": "nic-system", "name": "nebari-operator-controller"}}, + ] + } + + namespace, name = kg.find_operator_deployment(deployments) + + assert namespace == "nic-system" + assert name == "nebari-operator-controller" + + +def test_find_operator_deployment_raises_when_none_found(): + deployments = {"items": [{"metadata": {"namespace": "kube-system", "name": "coredns"}}]} + + try: + kg.find_operator_deployment(deployments) + raise AssertionError("expected OperatorNotFoundError") + except kg.OperatorNotFoundError: + pass + + +# --- extract_env_value (pure) ------------------------------------------------- + + +def test_extract_env_value_finds_named_var(): + env = [{"name": "OTHER", "value": "x"}, {"name": "KC_HOSTNAME", "value": "https://kc.example.com"}] + + assert kg.extract_env_value(env, "KC_HOSTNAME") == "https://kc.example.com" + + +def test_extract_env_value_returns_empty_string_when_absent(): + assert kg.extract_env_value([{"name": "OTHER", "value": "x"}], "KC_HOSTNAME") == "" + + +# --- wait_until_both_match (pure, time/sleep injected) ------------------------ + + +def test_wait_until_both_match_returns_true_as_soon_as_both_equal_expected(): + calls = [] + + def get_values(): + calls.append(1) + # Wrong on the first call, correct on the second. + return ("wrong", "wrong") if len(calls) == 1 else ("expected", "expected") + + fake_time = {"t": 0.0} + matched, values = kg.wait_until_both_match( + get_values, "expected", timeout_s=60, poll_interval_s=5, + clock=lambda: fake_time["t"], + sleep=lambda s: fake_time.__setitem__("t", fake_time["t"] + s), + ) + + assert matched is True + assert values == ("expected", "expected") + assert len(calls) == 2 + + +def test_wait_until_both_match_returns_false_after_timeout(): + fake_time = {"t": 0.0} + + matched, values = kg.wait_until_both_match( + lambda: ("wrong", "wrong"), "expected", timeout_s=20, poll_interval_s=5, + clock=lambda: fake_time["t"], + sleep=lambda s: fake_time.__setitem__("t", fake_time["t"] + s), + ) + + assert matched is False + assert values == ("wrong", "wrong") + + +def test_wait_until_both_match_false_when_only_one_side_matches(): + fake_time = {"t": 0.0} + + matched, _ = kg.wait_until_both_match( + lambda: ("expected", "wrong"), "expected", timeout_s=1, poll_interval_s=5, + clock=lambda: fake_time["t"], + sleep=lambda s: fake_time.__setitem__("t", fake_time["t"] + s), + ) + + assert matched is False diff --git a/tests/unit/test_preview_tunnel.py b/tests/unit/test_preview_tunnel.py new file mode 100644 index 0000000..5732876 --- /dev/null +++ b/tests/unit/test_preview_tunnel.py @@ -0,0 +1,294 @@ +"""Tests for scripts/preview/tunnel.py's cloudflared runner + extend-preview loop.""" + +from __future__ import annotations + +from scripts.preview import tunnel + +REPO = "nebari-dev/data-science-pack" +TOKEN = "gh-token" +URL = "https://pr-205-data-science-pack.openteams.app" +KC_URL = "https://keycloak-pr-205-data-science-pack.openteams.app" +DEPLOYED_AT = "2026-09-03 11:05 UTC" +DEPLOYED_AT_ISO = "2026-09-03T11:05:55Z" + + +def _run_kwargs(**overrides): + kwargs = { + "cloudflared_path": "/tmp/cloudflared", "tunnel_token": "tok", "repo": REPO, "pr_number": 205, + "github_token": TOKEN, "url": URL, "keycloak_url": KC_URL, + "deployed_at": DEPLOYED_AT, "deployed_at_iso": DEPLOYED_AT_ISO, + "wall_clock": lambda: 0.0, + "list_labels_fn": lambda *a: [], "delete_label_fn": lambda *a: None, + "find_comment_id_fn": lambda *a: None, "update_comment_fn": lambda *a: None, + } + kwargs.update(overrides) + return kwargs + + +# --- next_deadline / should_stop (pure) --------------------------------------- + + +def test_next_deadline_resets_to_now_plus_extend_when_label_present(): + # A reset, not a cumulative add: the old deadline is irrelevant once + # the label is seen. + assert tunnel.next_deadline(now=100.0, current_deadline=105.0, label_present=True, extend_seconds=1200) == 1300.0 + + +def test_next_deadline_unchanged_when_label_absent(): + assert tunnel.next_deadline(now=100.0, current_deadline=105.0, label_present=False, extend_seconds=1200) == 105.0 + + +def test_should_stop_true_when_process_no_longer_alive(): + assert tunnel.should_stop(alive=False, now=0.0, deadline=1000.0) is True + + +def test_should_stop_true_when_deadline_reached(): + assert tunnel.should_stop(alive=True, now=1000.0, deadline=1000.0) is True + + +def test_should_stop_false_while_alive_and_before_deadline(): + assert tunnel.should_stop(alive=True, now=500.0, deadline=1000.0) is False + + +# --- format_deadline (pure) ---------------------------------------------------- + + +def test_format_deadline_formats_human_and_iso_strings(): + # 2026-09-03T15:01:16Z as a Unix timestamp. + import calendar + import datetime + + epoch = calendar.timegm(datetime.datetime(2026, 9, 3, 15, 1, 16, tzinfo=datetime.timezone.utc).timetuple()) + + human, iso = tunnel.format_deadline(epoch) + + assert human == "2026-09-03 15:01 UTC" + assert iso == "2026-09-03T15:01:16Z" + + +# --- run (subprocess + label polling, all injected) --------------------------- + + +class _FakeProcess: + def __init__(self, exit_after_polls=None, exit_code=0): + self._polls = 0 + self._exit_after_polls = exit_after_polls + self.exit_code = exit_code + self.returncode = None + self.terminated = False + self.killed = False + + def poll(self): + self._polls += 1 + if self._exit_after_polls is not None and self._polls >= self._exit_after_polls: + self.returncode = self.exit_code + return self.returncode + + def terminate(self): + self.terminated = True + self.returncode = 0 + + def kill(self): + self.killed = True + self.returncode = -9 + + def wait(self, timeout=None): + return self.returncode + + +def _fake_clock_sleep(): + state = {"t": 0.0} + return (lambda: state["t"]), (lambda s: state.__setitem__("t", state["t"] + s)) + + +def test_run_returns_cloudflared_exit_code_on_real_crash(): + proc = _FakeProcess(exit_after_polls=2, exit_code=7) + clock, sleep = _fake_clock_sleep() + + rc = tunnel.run(**_run_kwargs( + initial_seconds=1200, poll_seconds=15, + popen=lambda *a, **k: proc, clock=clock, sleep=sleep, + )) + + assert rc == 7 + assert proc.terminated is False + + +def test_run_terminates_process_and_returns_0_at_deadline(): + proc = _FakeProcess() # never exits on its own + clock, sleep = _fake_clock_sleep() + + rc = tunnel.run(**_run_kwargs( + initial_seconds=30, poll_seconds=15, + popen=lambda *a, **k: proc, clock=clock, sleep=sleep, + )) + + assert rc == 0 + assert proc.terminated is True + + +def test_run_extends_deadline_when_label_seen_and_deletes_it(): + # initial_seconds=30, poll_seconds=15: checks happen at t=0 (absent), + # t=15 (present -> deadline resets to 15+30=45, clearly past the + # original 30s deadline), t=30 (absent, 30 < 45 so it keeps going), + # t=45 (stop: 45 >= 45). If the extend hadn't taken effect, the loop + # would have stopped at t=30 instead. + proc = _FakeProcess() + clock, sleep = _fake_clock_sleep() + label_calls = [] + delete_calls = [] + + def list_labels_fn(*a): + label_calls.append(1) + return ["extend-preview"] if len(label_calls) == 2 else [] + + def delete_label_fn(*a): + delete_calls.append(a) + + rc = tunnel.run(**_run_kwargs( + initial_seconds=30, poll_seconds=15, extend_seconds=30, + popen=lambda *a, **k: proc, clock=clock, sleep=sleep, + list_labels_fn=list_labels_fn, delete_label_fn=delete_label_fn, + )) + + assert rc == 0 + assert len(delete_calls) == 1 + assert delete_calls[0] == (REPO, 205, "extend-preview", TOKEN) + # Stopped at t=45 (the extended deadline), not t=30 (the original one). + assert clock() == 45.0 + + +def test_run_updates_the_pr_comment_with_the_new_expiry_on_extend(): + # Root cause of the bug this covers: the PR comment's "Expires" text + # is only ever rendered once, at deploy time -- extending the tunnel's + # internal deadline did nothing to it. On a successful extend, run() + # must now re-render the ready comment with the NEW expiry and PATCH + # it directly (the sticky-comment action only runs at fixed workflow + # steps, not from inside this loop). + proc = _FakeProcess() + clock, sleep = _fake_clock_sleep() + label_calls = [] + find_calls = [] + update_calls = [] + + def list_labels_fn(*a): + label_calls.append(1) + return ["extend-preview"] if len(label_calls) == 1 else [] + + def find_comment_id_fn(*a): + find_calls.append(a) + return 999 + + def update_comment_fn(*a): + update_calls.append(a) + + tunnel.run(**_run_kwargs( + initial_seconds=30, poll_seconds=15, extend_seconds=30, + popen=lambda *a, **k: proc, clock=clock, sleep=sleep, + list_labels_fn=list_labels_fn, delete_label_fn=lambda *a: None, + find_comment_id_fn=find_comment_id_fn, update_comment_fn=update_comment_fn, + )) + + assert len(find_calls) == 1 + assert find_calls[0][:3] == (REPO, 205, tunnel.STICKY_MARKER) + assert len(update_calls) == 1 + repo, comment_id, body, token = update_calls[0] + assert repo == REPO + assert comment_id == 999 + assert token == TOKEN + assert "🟢 [Ready]" in body + assert URL in body + assert KC_URL in body + # New expiry (t=0 + 30s = 1970-01-01 00:00:30 UTC), not the original. + assert "1970-01-01T00:00:30Z" in body + assert body.endswith(tunnel.STICKY_MARKER) + + +def test_run_computes_comment_expiry_from_wall_clock_not_monotonic_clock(): + # Regression test for a real bug found live: format_deadline() must + # never be fed the monotonic `clock`'s value directly. time.monotonic() + # has no relationship to the real epoch (often just seconds since + # process/boot start) -- interpreting it as Unix time produced + # "1970-01-01" in the actual PR comment. The extended deadline must be + # computed as wall_clock() + the remaining duration (deadline - the + # monotonic now), never the monotonic deadline interpreted as epoch + # time and never wall_clock() alone (ignoring how much time is left). + proc = _FakeProcess() + # Both clocks advance in lockstep on sleep() (as real time.monotonic() + # and time.time() would), just starting from very different epochs -- + # that mismatch is exactly what the fix must account for. + state = {"mono": 1913.0, "wall": 1893456000.0} + + def sleep(seconds): + state["mono"] += seconds + state["wall"] += seconds + + label_calls = [] + update_calls = [] + + def list_labels_fn(*a): + label_calls.append(1) + return ["extend-preview"] if len(label_calls) == 1 else [] + + tunnel.run(**_run_kwargs( + initial_seconds=30, poll_seconds=15, extend_seconds=30, + popen=lambda *a, **k: proc, + clock=lambda: state["mono"], wall_clock=lambda: state["wall"], sleep=sleep, + list_labels_fn=list_labels_fn, delete_label_fn=lambda *a: None, + find_comment_id_fn=lambda *a: 999, + update_comment_fn=lambda *a: update_calls.append(a), + )) + + assert len(update_calls) == 1 + body = update_calls[0][2] + # Extend at mono now=1913 -> new deadline=1943 -> 30s remaining. + # Wall-clock expiry = wall_now(1893456000) + 30 = 1893456030. + expected_iso = tunnel.format_deadline(1893456030)[1] + assert expected_iso in body + assert "1970-01-01" not in body + + +def test_run_skips_comment_update_when_comment_not_found(): + proc = _FakeProcess() + clock, sleep = _fake_clock_sleep() + label_calls = [] + update_calls = [] + + def list_labels_fn(*a): + label_calls.append(1) + return ["extend-preview"] if len(label_calls) == 1 else [] + + tunnel.run(**_run_kwargs( + initial_seconds=30, poll_seconds=15, extend_seconds=30, + popen=lambda *a, **k: proc, clock=clock, sleep=sleep, + list_labels_fn=list_labels_fn, delete_label_fn=lambda *a: None, + find_comment_id_fn=lambda *a: None, + update_comment_fn=lambda *a: update_calls.append(a), + )) + + assert update_calls == [] + + +def test_run_survives_a_comment_update_failure(): + # A GitHub API hiccup while updating the comment must never take down + # the tunnel itself -- the preview staying up matters more than the + # comment being perfectly accurate. + proc = _FakeProcess() + clock, sleep = _fake_clock_sleep() + label_calls = [] + + def list_labels_fn(*a): + label_calls.append(1) + return ["extend-preview"] if len(label_calls) == 1 else [] + + def boom(*a): + raise RuntimeError("GitHub API is down") + + rc = tunnel.run(**_run_kwargs( + initial_seconds=30, poll_seconds=15, extend_seconds=30, + popen=lambda *a, **k: proc, clock=clock, sleep=sleep, + list_labels_fn=list_labels_fn, delete_label_fn=lambda *a: None, + find_comment_id_fn=boom, + )) + + assert rc == 0 # the tunnel still ran to completion despite the failure