Zum Inhalt springen

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.

  • Einen API-Schlüssel aus API keys & agents, angelegt von einem Team-Admin, mit den Scopes k3s:read, k3s:write, k3s:access und k3s:destructive. Leg ihn als Repository-Secret PAASBOX_API_KEY ab. 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) und ACME_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.yaml aus Schritt 2 im Repository, einen Build, der das Image des Branches pusht, und einen Testbefehl, hier ./smoke-test.sh.
  1. Leg .github/workflows/test-cluster.yml an:

    name: test-cluster
    on: pull_request
    jobs:
    test:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    env:
    PAASBOX_PORTAL: ${{ vars.PAASBOX_PORTAL }}
    PAASBOX_API_KEY: ${{ secrets.PAASBOX_API_KEY }}
    API: ${{ vars.PAASBOX_PORTAL }}/api/v1/teams/${{ vars.PAASBOX_TEAM }}/k3s
    AUTH: "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 cluster
    run: |
    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 on
    run: |
    while :; do
    state=$(curl -fsS -H "$AUTH" "$API/clusters/$SLUG/" | jq -r .state)
    [ "$state" = ready ] && break
    [ "$state" = failed ] && exit 1
    sleep 10
    done
    body=$(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 :; do
    state=$(curl -fsS -H "$AUTH" "$API/clusters/$SLUG/addons/" | jq -r \
    '.addons[] | select(.name == "paasbox-platform") | .reported.state')
    [ "$state" = applied ] && break
    [ "$state" = failed ] && exit 1
    sleep 10
    done
    - name: Get a kubeconfig
    run: |
    curl -fsSO "$PAASBOX_PORTAL/mcp/paasbox_kubeconfig.py" && pip install cryptography
    python3 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 branch
    run: |
    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: Test
    run: |
    kubectl -n upcheck port-forward svc/upcheck-web 8000:80 &
    sleep 3
    ./smoke-test.sh http://localhost:8000
    - name: Delete the cluster
    if: always() && env.SLUG != ''
    run: curl -fsS -X DELETE -H "$AUTH" "$API/clusters/$SLUG/?confirm=$NAME&finalSnapshot=false"
  2. Ö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:

  • confirm muss der Name des Clusters sein, beim Anlegen und beim Löschen; jeder andere Wert wird mit 422 und dem Code confirmation_required abgelehnt. 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).
  • hcloudToken copy legt 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-platform schaltet die PaaSbox Platform an und mit also das Add-on Flux, das sie braucht. Die Plattform braucht acmeEmail fü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-on applied meldet; failed beendet den Job. Im Labor waren Flux und die Plattform, in einem Speichern angeschaltet, nach 2 Minuten 53 Sekunden applied, und upcheck war 82 Sekunden nach kubectl apply Ready. 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 der SaaSApplication, nicht im Namespace, den die Datei auch enthält, und entfernt spec.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 Service upcheck-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 allowedApiRanges auf 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.

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:

Terminal-Fenster
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"
done

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).

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.