Ein Testcluster pro Pull Request
Dieses Rezept gibt jedem Pull Request einen eigenen Cluster: Die CI legt einen Wegwerf-Cluster an, deployt den Branch darauf, lässt die Tests laufen und löscht ihn wieder, ob die Tests bestanden haben oder nicht. Es ist die Schleife aus Lernschritt 3, gefahren von GitHub Actions über die REST-API.
Was du brauchst
Abschnitt betitelt „Was du brauchst“- Einen API-Schlüssel aus API keys & agents, angelegt von einem Team-Admin, mit den Scopes
k3s:read,k3s:write,k3s:accessundk3s:destructive. Leg ihn als Repository-SecretPAASBOX_API_KEYab. Er kann jeden Cluster seines Teams löschen: Halte ihn nur in der CI, und halte die Produktion in einem Team, zu dem der Schlüssel nicht gehört. - Ein Hetzner-Projekt für Testcluster, mit dem Portal verbunden. Seine ID steht in
GET …/k3s/projects/. - Vier Repository-Variablen:
PAASBOX_PORTAL(die Adresse des Portals),PAASBOX_TEAM(der Slug deines Teams),PAASBOX_PROJECT(die ID des Projekts) undACME_EMAIL(die Adresse, unter der Let’s Encrypt die Zertifikate der Plattform registriert). API keys & agents zeigt die REST-Adresse, die sich aus den ersten beiden ergibt. - Ein Team, das sein Abonnement schon hat. Der erste Cluster eines Teams startet das Abonnement im Checkout des Portals und wird im Portal angelegt, nicht über die API (
402 payment_method_required). Dasselbe gilt wieder, wenn der letzte Cluster des Teams weg ist und der schon bezahlte Monat zu Ende ist. - Ein Release mit dem Add-on PaaSbox Platform im Kanal stable, Release 2026.10.2 oder später. Ein neuer Cluster bekommt das Release, das sein Kanal anbietet, stable, solange du nicht early wählst.
- Deine
upcheck.yamlaus Schritt 2 im Repository, einen Build, der das Image des Branches pusht, und einen Testbefehl, hier./smoke-test.sh.
Der Workflow
Abschnitt betitelt „Der Workflow“-
Leg
.github/workflows/test-cluster.ymlan:name: test-clusteron: pull_requestjobs:test:runs-on: ubuntu-latesttimeout-minutes: 45env:PAASBOX_PORTAL: ${{ vars.PAASBOX_PORTAL }}PAASBOX_API_KEY: ${{ secrets.PAASBOX_API_KEY }}API: ${{ vars.PAASBOX_PORTAL }}/api/v1/teams/${{ vars.PAASBOX_TEAM }}/k3sAUTH: "Authorization: Bearer ${{ secrets.PAASBOX_API_KEY }}"NAME: upcheck-pr-${{ github.event.pull_request.number }}-${{ github.run_number }}IMAGE: registry.example.com/upcheck:${{ github.sha }}steps:- uses: actions/checkout@v4# Hier $IMAGE bauen und pushen.- name: Create the clusterrun: |body=$(jq -n --arg n "$NAME" --argjson p "${{ vars.PAASBOX_PROJECT }}" \'{name: $n, confirm: $n, project: $p, location: "fsn1", serverType: "cpx22",hcloudToken: {mode: "copy"}}')slug=$(curl -fsS -X POST "$API/clusters/" -H "$AUTH" \-H "Content-Type: application/json" -d "$body" | jq -r .cluster.slug)echo "SLUG=$slug" >> "$GITHUB_ENV"- name: Wait for the cluster, then switch the platform onrun: |while :; dostate=$(curl -fsS -H "$AUTH" "$API/clusters/$SLUG/" | jq -r .state)[ "$state" = ready ] && break[ "$state" = failed ] && exit 1sleep 10donebody=$(jq -n --arg e "${{ vars.ACME_EMAIL }}" \'{enabled: true, options: {profile: "saas-http01", acmeEmail: $e}, also: ["flux"]}')curl -fsS -X PATCH "$API/clusters/$SLUG/addons/paasbox-platform/" -H "$AUTH" \-H "Content-Type: application/json" -d "$body"while :; dostate=$(curl -fsS -H "$AUTH" "$API/clusters/$SLUG/addons/" | jq -r \'.addons[] | select(.name == "paasbox-platform") | .reported.state')[ "$state" = applied ] && break[ "$state" = failed ] && exit 1sleep 10done- name: Get a kubeconfigrun: |curl -fsSO "$PAASBOX_PORTAL/mcp/paasbox_kubeconfig.py" && pip install cryptographypython3 paasbox_kubeconfig.py fetch --team ${{ vars.PAASBOX_TEAM }} \--cluster "$SLUG" --role admin > "$RUNNER_TEMP/kubeconfig"chmod 600 "$RUNNER_TEMP/kubeconfig"echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"- name: Deploy the branchrun: |yq '(select(.kind == "SaaSApplication") | .spec.image) = strenv(IMAGE) | del(.spec.exposure)' \upcheck.yaml | kubectl apply -f -kubectl -n upcheck wait saasapp/upcheck --for=jsonpath='{.status.phase}'=Ready --timeout=15m- name: Testrun: |kubectl -n upcheck port-forward svc/upcheck-web 8000:80 &sleep 3./smoke-test.sh http://localhost:8000- name: Delete the clusterif: always() && env.SLUG != ''run: curl -fsS -X DELETE -H "$AUTH" "$API/clusters/$SLUG/?confirm=$NAME&finalSnapshot=false" -
Öffne einen Pull Request. Das Log des Jobs zeigt den Zustand des Clusters, während er wartet; im Labor war ein Cluster 2 Minuten 46 Sekunden nach der Anfrage bereit. Der erste Cluster im Testprojekt wartet außerdem darauf, dass das Node-Image hineinkopiert wird, im Labor 69 Sekunden; spätere nutzen die Kopie.
Worauf jeder Teil sich verlässt:
confirmmuss der Name des Clusters sein, beim Anlegen und beim Löschen; jeder andere Wert wird mit422und dem Codeconfirmation_requiredabgelehnt. Das Anlegen macht außerdem die Prüfungen der Seite zum Anlegen: ein Release im Kanal stable, die Grenze deines Teams von 10 Clustern (quota_exceeded), die akzeptierten Bedingungen, die Abrechnung (payment_method_required, solange das Team kein Abonnement hat).hcloudTokencopylegt eine Kopie des Projekt-Tokens in den Cluster, so hat die CI nie ein Hetzner-Token in der Hand. In einem Projekt, das nur Testcluster hält, erreicht die Kopie nichts sonst.- Der PATCH von
paasbox-platformschaltet die PaaSbox Platform an und mitalsodas Add-on Flux, das sie braucht. Die Plattform brauchtacmeEmailfür ihr Konto bei Let’s Encrypt, und ein Anlegen nimmt nur das Profil der Plattform, nicht diese Option; darum geht die Plattform an, sobald der Cluster bereit ist. Sie ist bereit, wenn das Add-onappliedmeldet;failedbeendet den Job. Im Labor waren Flux und die Plattform, in einem Speichern angeschaltet, nach 2 Minuten 53 Sekundenapplied, und upcheck war 82 Sekunden nachkubectl applyReady. Einen Teil der Plattform, der einmal scheitert, versucht Flux erst nach einer Stunde wieder, ein noch offener Fehler, den die 45 Minuten des Jobs nicht abdecken. - Die
yq-Zeile setzt das Image des Branches nur in derSaaSApplication, nicht im Namespace, den die Datei auch enthält, und entferntspec.exposure. Ohne sie bekommt die App keinen Ingress, kein Zertifikat und keinen DNS-Namen, und die Tests erreichen sie über einen Port-Forward auf den Serviceupcheck-web, Port 80. - Die API-Bereiche bleiben für alle offen, weil ein von GitHub gehosteter Runner keine feste Adresse hat; die API braucht trotzdem das Token der Kubeconfig. Mit einem eigenen Runner setzt du
allowedApiRangesauf seine Adresse. if: always()löscht den Cluster auch dann, wenn ein Schritt davor fehlgeschlagen ist oder der Lauf abgebrochen wurde. Mit dem Löschen zählt der Cluster nicht mehr für die Abrechnung; im Labor dauerte ein Löschen 14 Sekunden.
Aufräumen, was übrig blieb
Abschnitt betitelt „Aufräumen, was übrig blieb“Ein Lauf, der vor seinem letzten Schritt stirbt, etwa weil ein Runner verschwindet, lässt seinen Cluster zurück. Ein zweiter Workflow löscht nach Zeitplan jeden Testcluster, der älter als zwei Stunden ist:
cut=$(date -u -d '-2 hours' +%FT%TZ)curl -fsS -H "$AUTH" "$API/clusters/?page_size=100" \ | jq -r --arg cut "$cut" '.items[] | select((.name | startswith("upcheck-pr-")) and .createdAt < $cut and .state != "deleting") | "\(.slug) \(.name)"' \ | while read -r slug name; do curl -fsS -X DELETE -H "$AUTH" "$API/clusters/$slug/?confirm=$name&finalSnapshot=false" doneWas es kostet
Abschnitt betitelt „Was es kostet“Jeder Lauf ist ein Cluster, gezählt ab dem Moment, in dem er zum ersten Mal bereit ist, bis zum Löschen. In Arbeit So wie die Abrechnung über Paddle geschrieben ist, wird ein Testcluster neben einem Cluster, den das Team behält, anteilig berechnet, sobald er bereit ist, und für den Rest des Monats gutgeschrieben, wenn er gelöscht wird. In einem Team, dessen einzige Cluster Testcluster sind, wird der Monat bezahlt, wenn der erste angelegt wird, und Testcluster in diesem Monat nutzen ihn; nach einem Monat, an dessen Ende kein Cluster da war, braucht der nächste wieder den Checkout im Portal (Kosten und Abrechnung). Den Server rechnet Hetzner stundenweise ab, zu seinem eigenen Preis. Jeder laufende Testcluster zählt gegen die Grenze deines Teams von 10 Clustern, neben Staging und Produktion: Mit Staging und Produktion einer App können acht Pull Requests gleichzeitig einen Cluster haben. Frag nach einer höheren Grenze, wenn du mehr brauchst. Ein Schlüssel ist außerdem darin begrenzt, wie viele Aufrufe er pro Minute macht: Frag alle zehn Sekunden ab, nicht öfter (Grenzen und Kontingente).
Mit einem Agenten statt der CI
Abschnitt betitelt „Mit einem Agenten statt der CI“Dieselben Schritte gehen über die MCP-Tools, wie in Lernschritt 3: Gib deinem Agenten das Image des Pull Requests und die Aufgabe, und lass create_cluster und delete_cluster bei dir nachfragen. Dann bestätigst du jeden Cluster von Hand, was die CI nicht kann.