Skip to content

ci: add k8s stack PR preview via labeled deploy + Cloudflare Tunnel - #205

Draft
aktech wants to merge 52 commits into
mainfrom
add-k8s-preview-workflow
Draft

ci: add k8s stack PR preview via labeled deploy + Cloudflare Tunnel#205
aktech wants to merge 52 commits into
mainfrom
add-k8s-preview-workflow

Conversation

@aktech

@aktech aktech commented Aug 3, 2026

Copy link
Copy Markdown
Member

Adds a deploy-preview label-triggered workflow that spins up the full NIC platform stack plus this chart in an ephemeral k3d cluster, then exposes JupyterHub through a per-PR Cloudflare Tunnel gated by Cloudflare Access (GitHub org SSO), posting the link as a sticky PR comment.

Also adds a standalone workflow_dispatch smoketest workflow that validates just the Cloudflare Tunnel/DNS/Access plumbing against a trivial static page, for fast iteration without paying for the full stack deploy each time.

Draft PR to test the preview workflow on itself — add the deploy-preview label to try it.

Labeling a PR with deploy-preview spins up the full NIC platform stack
plus this chart in an ephemeral k3d cluster, then exposes JupyterHub
through a per-PR Cloudflare Tunnel gated by Cloudflare Access (GitHub
org SSO), posting the link as a sticky PR comment. Includes a standalone
tunnel-only smoketest workflow for validating the Cloudflare plumbing
without the full stack deploy.
@aktech aktech added deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) and removed deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The K8s stack preview for this PR has expired.

Project Deployment Actions Updated
nebari-data-science-pack ⚫ Expired - 2026-09-01 15:33 UTC

Push a new commit or re-add the deploy-preview label to redeploy.

aktech added 4 commits August 3, 2026 14:47
pull_request/synchronize on this one file, scoped narrowly, so the
tunnel-only smoketest can run without waiting for main to see the
workflow_dispatch trigger. Remove before merging.
--no-autoupdate is a tunnel-level flag, not a run-subcommand flag;
placed after `run` it errored with "flag provided but not defined"
and the tunnel process exited immediately without ever connecting.
Two-level hostnames (pr-<n>.dspack.iakte.ch) aren't covered by
Cloudflare's default Universal SSL, which only auto-issues a cert for
the zone apex plus one wildcard level. Switching to a dedicated
single-level domain (pr-<n>.github.fyi) avoids needing the paid
Advanced Certificate Manager add-on.
workflow_dispatch is now registered and reachable; the iteration
workaround is no longer needed.
@aktech aktech added the deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) label Aug 3, 2026
aktech added 2 commits August 3, 2026 15:18
charts/ is gitignored, so the vendored jupyterhub-4.3.2.tgz seen in
local dev never reaches a fresh CI checkout. helm dependency build
resolves it against the already-committed Chart.lock digest.
workflow_dispatch has no PR context to auto-detect, so the comment
step uses number_force (sticky-pull-request-comment's any-event PR
number override, distinct from number which is push-event only).
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Tunnel smoketest (static page, not the real stack):
https://smoketest-33391777740.openteams.app

Deployed: 2026-08-31 12:26 UTC · Expires: 2026-08-31 12:41 UTC

Sign-in via Cloudflare Access (GitHub SSO), then should show "Tunnel smoketest OK".

aktech and others added 7 commits August 3, 2026 15:28
helm dependency build resolves against locally-registered repos, not
just the Chart.lock digest; a fresh runner has none configured. Added
helm repo add before the build step. Verified against a clean clone.
…debugging

nic-operator ignores any namespace without nebari.dev/managed=true, so
the NebariApp CRD never reconciled. Also adds a static pod/job/event
dump and an interactive tmate SSH session (actor-restricted, 20min cap)
on deploy failure, since the cluster is deleted right after and static
logs alone weren't enough to diagnose the last two failures.
00-gateway-auth.py reads /etc/oauth/issuer-url unconditionally at
import time. That file only exists once the operator's async Keycloak
client provisioning finishes, which Helm doesn't wait for, so the hub
pod crash-loops immediately when nebariapp.auth.enabled=true. Confirmed
via kubectl logs during a failed run. Dropping nebariapp entirely for
this preview; operator/OIDC reconcile isn't exercised here.
Without it, 02-jhub-apps.py falls back to bind_url=http://0.0.0.0:8000,
which JupyterHub then bakes into browser-facing OAuth redirect URLs for
the jhub-apps service (client_id=service-japps), breaking the login
flow after dummy-auth. Confirmed via a real login attempt on the
deployed preview.
The preview URL is stable per-PR, so redeploys posted byte-identical
comment text and looked like they never updated. Adding deployed-at
and expires-at timestamps makes every redeploy visibly change the
comment and tells reviewers exactly when the link goes dead.
…ri-data-science-pack into add-k8s-preview-workflow
@aktech aktech added deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) and removed deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) labels Aug 5, 2026
aktech and others added 4 commits August 5, 2026 15:14
A workflow retry keeps the same github.run_id (only run_attempt
changes), so a re-run after an attempt already created the tunnel but
didn't reach its cleanup step hit a 409 name conflict. Now falls back
to looking up and reusing the existing tunnel by name instead of
failing outright. Confirmed via a real run_attempt=2 failure.
PREVIEW_DOMAIN now reads from vars.PREVIEW_DOMAIN (repo Settings ->
Actions -> Variables), set to github.fyi, in both the real preview
workflow and the smoketest. Repointing to a different domain no longer
needs a code change.
Same account already holds both the tunnel (Cloudflare Tunnel:Edit)
and the openteams.app zone (Zone:Read, DNS:Edit) on the existing
CLOUDFLARE_TUNNEL_API_TOKEN, so no new token needed. Hostnames flatten
to pr-<n>-data-science-pack.openteams.app (single label) to stay
within Cloudflare's free Universal SSL one-wildcard-level coverage.
@aktech aktech added deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) and removed deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) labels Aug 31, 2026
aktech added 3 commits August 31, 2026 13:37
Drops the removed profile/k3d-version/k8s-version inputs, installs kind
via helm/kind-action, swaps k3d image import + manual cluster delete for
kind load docker-image (v3 auto-tears-down the cluster on its own).
kind load docker-image failed with "unknown containerd config version: 4"
against the sandbox's kind node image. Support for that format landed in
kind v0.32.0; v0.27.0 (matching test.yaml, whose own kind-created cluster
doesn't hit this) predates it.
A live browser login through the preview link 502s on
/services/japps/oauth_callback. jhub-apps runs as a managed-service
subprocess inside the hub pod, so a crash there doesn't fail helm --wait
or the rollout status checks -- add an unconditional hub log dump so the
cause shows up in the run's own output.
aktech added 9 commits August 31, 2026 17:38
Both Keycloak and nebari-operator are ArgoCD Applications with
selfHeal: true, reconciled against NIC's auto-created local GitOps repo.
A direct kubectl set env DID apply (confirmed live: both workloads
genuinely rolled to new pods), but ArgoCD's reconcile loop silently
reverted it within a couple minutes once it noticed the drift from the
GitOps repo -- hub's redirect still landed on the unreachable
keycloak.nebari.local well after the "successful" patch.

Edit the two source files in the GitOps repo directly
(values/keycloak/base.yaml, manifests/nebari-operator/deployment-patch.yaml),
commit, and force an ArgoCD hard refresh so selfHeal applies our change
instead of fighting it -- the same pattern already used for this
project's other ArgoCD-managed clusters. Poll for the live env var to
actually change before trusting rollout status, since a stale refresh
can make rollout status return instantly without ArgoCD having applied
anything yet.
Cloudflare Access is the real security boundary for this preview -- only
allow-listed accounts reach the tunnel at all -- so a simple, known
Keycloak password is fine here rather than making reviewers hunt for
real credentials on a throwaway cluster. Creates the user via Keycloak's
admin API, piping the admin secret straight into the token request
without ever echoing or logging it. PR comment now states the
credentials directly.
…cess

Confirmed live: after a real login, hub's OAuth callback hit
json.decoder.JSONDecodeError: Expecting value (an empty/non-JSON body)
on the token exchange. Keycloak's public hostname sits behind Cloudflare
Access same as the JupyterHub hostname -- fine for the browser-facing
authorize/login legs since the reviewer already has an Access session,
but hub's own server-to-server token/userinfo calls have no such session
and get blocked by Access instead of a JSON response.

Set keycloak.backchannelURL to Keycloak's in-cluster Service so only
token_url/userdata_url move there -- authorize_url and end_session_url
stay on the public issuer, which is what the browser actually needs to
reach. This is exactly the split-horizon path config/jupyterhub/00-gateway-auth.py
already supported; just needed the value set.
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
(a ClusterIP self-reference). Routing same-pod traffic through a
Service depends on the CNI supporting hairpin NAT for a pod reaching
its own Service -- confirmed via a live preview to time out
(httpcore.ConnectTimeout) on a kind/kindnet cluster, breaking
/services/japps/oauth_callback with a 500 right after a successful
Keycloak login.

Rewrite the host to localhost (same port/path) before forwarding it
into the japps service's subprocess environment. This is a general
fix, not preview-specific: same-pod loopback is always reliable
regardless of hairpin NAT support on whatever CNI a real deployment
happens to run.
Confirmed live: setting svc["environment"]["JUPYTERHUB_API_URL"] (same
pattern as the existing OIDC secret forwarding) had no effect -- the
subprocess still saw the `hub` Service URL and still timed out.

JupyterHub's own Spawner.get_env() computes
env['JUPYTERHUB_API_URL'] = hub_api_url from self.hub.api_url *after*
merging self.environment, unconditionally overwriting whatever a
service config sets. Only something applied after JupyterHub finishes
building the subprocess's env can actually win, so wrap the service's
own command with a shell-level `env VAR=value` assignment instead --
that's scoped to jhub-apps' own process and can't be overridden by the
parent.
Keycloak's default realm profile requires email/firstName/lastName,
so creating the user without them triggered a first-login "Update
Account Information" prompt every time. Set them at creation instead
so the reviewer account logs straight through.
The previous fix read os.environ.get("JUPYTERHUB_API_URL") in the hub
process's own config-load-time environment to precompute a localhost
URL to inject. That variable is empty there -- JupyterHub only computes
and injects it into a service's own environment at spawn time -- so the
rewrite was silently a no-op and jhub-apps kept timing out against the
`hub` Service.

Wrap the service's command with a shell snippet that rewrites
$JUPYTERHUB_API_URL to localhost (via sed, keeping port/path) at the
moment the subprocess execs, using whatever JupyterHub has actually put
in its environment by then.
The tunnel step ran unbounded and relied on the job's timeout-minutes
(90) to kill it, which makes GitHub mark the whole run "cancelled" with
a "job has exceeded the maximum execution time" failure annotation --
looks like a real failure even though the preview is meant to expire.

Bound the step itself to 20 minutes via `timeout`, treating its own
deadline (exit 124) as success, so the job finishes normally well under
the outer limit. Updates the reviewer-facing expiry timestamp and
comments to match.
The sticky PR comment edits in place (it was created on the PR's first
deploy), so it doesn't move in the thread and looked like it never
updated even though its content was current. Add a GitHub
Deployment/Environment (pr-<n>-preview) alongside it: GitHub renders
that as its own status box pinned near the top of the PR with the live
URL and timestamp, independent of the comment thread. Marked inactive
once the tunnel closes or the deploy-preview label is removed, so it
doesn't keep showing a dead link as green.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 11:00 Destroyed
GitHub Actions runs run: steps as bash -e. A plain
`timeout ...; ec=$?` doesn't work there -- -e aborts the step the
instant timeout returns 124 (its own deadline hit), before ec=$? is
ever reached, so the step showed as a real failure even on the
expected timeout path (confirmed live on run 33499572113).

Capture the exit code via `|| ec=$?` instead, which is a protected
context -e doesn't abort on.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 11:31 Destroyed
Drops the login walkthrough (obvious to anyone opening the link) and
lays out project/status/links/timestamp as a compact table instead, so
the comment reads at a glance like a Vercel-for-GitHub bot comment.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 11:57 Destroyed
Was showing the PR's branch ref, not a project name -- doesn't match
the Vercel-style table it's mimicking, where Project identifies the
thing being deployed. Use the chart's actual name instead.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 12:10 Destroyed
Reading "Expires: 2026-09-01 12:37 UTC" means doing timezone math by
hand to know if the preview is about to die. Use GitHub's own
<relative-time> web component (confirmed via gh api /markdown to
survive comment sanitization) so it reads "in 17 minutes" and keeps
ticking client-side, no re-editing the comment required. Falls back to
the plain UTC string as its text content before the element hydrates.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 12:21 Destroyed
The comment was only ever written once, at deploy time. <relative-time>
kept the clock ticking but the surrounding wording and green status
dot never changed, so an expired preview still read "Ready" next to
"Expires 39 minutes ago". Edit the same comment again during teardown
with the correct wording and an inactive status marker.

Also strips stray em dashes from workflow comments and the PR-facing
text.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 13:33 Destroyed
aktech and others added 2 commits September 1, 2026 14:56
Adding extend-preview any time before the preview expires pushes the
tunnel step's deadline back 20 minutes from that moment (a reset, not
a cumulative add), without tearing down and rebuilding the cluster.
The already-running tunnel step polls for the label every 15s and
consumes it by deleting it, so it can be reused any number of times
before the job's own 90min timeout-minutes catches up.

Scopes the job's labeled/unlabeled trigger to the deploy-preview label
specifically, since a labeled event fires for any label add -- without
this, adding extend-preview would start a second run of the job, which
concurrency: cancel-in-progress then uses to cancel the very run it
was meant to extend.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 14:07 Destroyed
@aktech aktech added the extend-preview Push this preview's expiry back 20 minutes label Sep 1, 2026
@github-actions github-actions Bot removed the extend-preview Push this preview's expiry back 20 minutes label Sep 1, 2026
The job-level if: condition added earlier only skips deploy-preview's
job for a labeled event on any label but deploy-preview -- it doesn't
stop concurrency: cancel-in-progress from cancelling the current run,
since that operates on the workflow run itself regardless of whether
the job inside it ends up running. Confirmed live: adding
extend-preview queued a new run that was about to cancel the one it
was supposed to extend.

cancel-in-progress now evaluates to false specifically for a labeled
event on extend-preview, so that new (skipped) run just queues behind
the current one instead of cancelling it.
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 14:18 Destroyed
@aktech aktech added deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) and removed deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated) labels Sep 1, 2026
@github-actions
github-actions Bot temporarily deployed to pr-205-preview September 1, 2026 15:13 Destroyed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy-preview Deploy a k8s stack preview for this PR (Cloudflare Tunnel, GitHub SSO gated)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants