diff --git a/.github/workflows/cluster-regression.yml b/.github/workflows/cluster-regression.yml index af49485..be231e7 100644 --- a/.github/workflows/cluster-regression.yml +++ b/.github/workflows/cluster-regression.yml @@ -6,6 +6,12 @@ name: cluster regression on: + pull_request: + paths: + - ".github/workflows/cluster-regression.yml" + - "helm/tekton-dag/**" + - "scripts/bootstrap-namespace.sh" + - "scripts/run-cluster-ci.sh" workflow_dispatch: inputs: isolation_repeats: @@ -112,6 +118,11 @@ jobs: if [[ "${{ github.event.inputs.skip_newman || false }}" == "true" ]]; then extra+=(--skip-newman) fi + # RBAC/chart PRs need the deterministic Tekton Phase 2 + Newman gate. + # Isolation benchmarking remains part of nightly/manual/tag runs. + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + extra+=(--skip-isolation) + fi # Script defaults WITH_OPERATOR=true. Honor dispatch uncheck only. if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.with_operator }}" == "false" ]]; then extra+=(--skip-operator) @@ -119,7 +130,18 @@ jobs: bash scripts/run-cluster-ci.sh --isolation-repeats "$ISOLATION_EVAL_REPEATS" \ --out "$ISOLATION_EVAL_OUT" "${extra[@]}" 2>&1 | tee cluster-ci.log - - name: Upload cluster CI log and CSV + - name: Collect cluster failure diagnostics + if: failure() + run: | + kubectl logs deployment/tekton-dag-orchestrator -n tekton-pipelines \ + --all-containers --tail=-1 > orchestrator.log 2>&1 || true + kubectl auth can-i --list \ + --as=system:serviceaccount:tekton-pipelines:tekton-pr-sa \ + > pipeline-rbac.log 2>&1 || true + kubectl get stackruns,pipelineruns -n tekton-pipelines -o yaml \ + > execution-resources.yaml 2>&1 || true + + - name: Upload cluster CI diagnostics if: always() uses: actions/upload-artifact@v4 with: @@ -127,5 +149,8 @@ jobs: path: | cluster-ci.log isolation-eval-measured.csv + orchestrator.log + pipeline-rbac.log + execution-resources.yaml if-no-files-found: warn retention-days: 30 diff --git a/.github/workflows/intercept-e2e.yml b/.github/workflows/intercept-e2e.yml new file mode 100644 index 0000000..70935f8 --- /dev/null +++ b/.github/workflows/intercept-e2e.yml @@ -0,0 +1,133 @@ +name: intercept product E2E + +on: + pull_request: + paths: + - ".github/workflows/intercept-e2e.yml" + - "helm/tekton-dag/**" + - "scripts/bootstrap-namespace.sh" + - "scripts/run-product-intercept-e2e.sh" + workflow_dispatch: + schedule: + # Weekly product-path evidence on the default branch. + - cron: "43 7 * * 1" + +permissions: + contents: read + +concurrency: + group: intercept-e2e-${{ github.ref }} + cancel-in-progress: false + +jobs: + product-path: + name: ${{ matrix.backend }} trigger-to-traffic + runs-on: ubuntu-latest + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + backend: [telepresence, mirrord] + env: + E2E_ARTIFACT_DIR: ${{ github.workspace }}/artifacts/${{ matrix.backend }} + E2E_GIT_SSH_PRIVATE_KEY: ${{ secrets.E2E_GIT_SSH_PRIVATE_KEY }} + INTERCEPT_E2E_TIMEOUT: "2100" + KIND_VERSION: "v0.27.0" + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: "22" + + - name: Install Kind and Newman + run: | + curl -fsSL -o /tmp/kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" + echo "a6875aaea358acf0ac07786b1a6755d08fd640f4c79b7a2e46681cc13f49a04b /tmp/kind" | sha256sum -c - + chmod +x /tmp/kind + sudo mv /tmp/kind /usr/local/bin/kind + npm install -g newman@6.2.1 + ./scripts/bootstrap-regression-venv.sh + + - name: Install cluster control plane + env: + CLUSTER_CI_GIT_REVISION: ${{ github.sha }} + CLUSTER_CI_GIT_URL: https://github.com/${{ github.repository }}.git + run: | + set -o pipefail + bash scripts/run-cluster-ci.sh --skip-isolation 2>&1 | tee cluster-setup.log + + - name: Install application clone credential + run: | + kubectl create namespace staging --dry-run=client -o yaml | kubectl apply -f - + kubectl label namespace staging \ + pod-security.kubernetes.io/enforce=privileged \ + pod-security.kubernetes.io/audit=privileged \ + pod-security.kubernetes.io/warn=privileged \ + --overwrite + secret_args=() + if [[ -n "$E2E_GIT_SSH_PRIVATE_KEY" ]]; then + install -d -m 700 /tmp/e2e-ssh + printf '%s\n' "$E2E_GIT_SSH_PRIVATE_KEY" > /tmp/e2e-ssh/id_ed25519 + chmod 600 /tmp/e2e-ssh/id_ed25519 + secret_args+=(--from-file=id_ed25519=/tmp/e2e-ssh/id_ed25519) + else + echo "No E2E SSH key configured; public application repositories will use HTTPS." + fi + kubectl create secret generic ssh-key-secret \ + -n tekton-pipelines \ + "${secret_args[@]}" \ + --dry-run=client -o yaml | kubectl apply -f - + rm -rf /tmp/e2e-ssh + + - name: Build pipeline tool images + run: | + set -o pipefail + bash build-images/build-and-push.sh localhost:5000 latest 2>&1 | tee build-images.log + + - name: Install Telepresence traffic manager + if: matrix.backend == 'telepresence' + run: ./scripts/install-telepresence-traffic-manager.sh + + - name: Run authenticated product path + env: + PR_NUMBER: ${{ github.run_number }} + run: | + set -o pipefail + bash scripts/run-product-intercept-e2e.sh \ + --intercept-backend "${{ matrix.backend }}" \ + --pr "$PR_NUMBER" 2>&1 | tee "intercept-${{ matrix.backend }}.log" + + - name: Collect failure diagnostics + if: failure() + run: | + mkdir -p "$E2E_ARTIFACT_DIR" + kubectl get stackrun,pipelinerun,taskrun -A -o yaml \ + > "$E2E_ARTIFACT_DIR/all-tekton-resources.yaml" 2>&1 || true + kubectl get pods,deployments,services -A -o wide \ + > "$E2E_ARTIFACT_DIR/all-workloads.txt" 2>&1 || true + kubectl logs -n tekton-pipelines -l app=tekton-dag-operator \ + --all-containers=true --prefix=true \ + > "$E2E_ARTIFACT_DIR/operator.log" 2>&1 || true + + - name: Upload product-path evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: intercept-e2e-${{ matrix.backend }}-${{ github.sha }} + path: | + artifacts/${{ matrix.backend }}/ + cluster-setup.log + build-images.log + intercept-${{ matrix.backend }}.log + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/local-regression.yml b/.github/workflows/local-regression.yml index be5ad01..470227d 100644 --- a/.github/workflows/local-regression.yml +++ b/.github/workflows/local-regression.yml @@ -56,6 +56,18 @@ jobs: go-version: "1.23" cache-dependency-path: operator/go.sum + - name: Install Helm + env: + HELM_VERSION: v4.3.0 + HELM_SHA256: 86584a54def73570558f66f5111cc53dfed56689637ae32c1201205d494f54fb + run: | + archive="helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl -fsSLo "/tmp/${archive}" "https://get.helm.sh/${archive}" + echo "${HELM_SHA256} /tmp/${archive}" | sha256sum -c - + tar -xzf "/tmp/${archive}" -C /tmp + sudo install /tmp/linux-amd64/helm /usr/local/bin/helm + helm version + - name: Bootstrap Python venv run: ./scripts/bootstrap-regression-venv.sh diff --git a/.github/workflows/operator.yml b/.github/workflows/operator.yml index 06a8d70..67579e6 100644 --- a/.github/workflows/operator.yml +++ b/.github/workflows/operator.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - "operator/**" + - "scripts/install-tekton.sh" - ".github/workflows/operator.yml" push: branches: [main] paths: - "operator/**" + - "scripts/install-tekton.sh" - ".github/workflows/operator.yml" workflow_dispatch: @@ -29,10 +31,10 @@ jobs: working-directory: operator steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: operator/go.mod cache-dependency-path: operator/go.sum @@ -40,11 +42,67 @@ jobs: - name: Check formatting run: test -z "$(gofmt -l .)" + - name: Lint + env: + # golangci-lint v2.13.2 is built with Go 1.26; setup-go otherwise + # forces GOTOOLCHAIN=local at the module's Go 1.23 version. + GOTOOLCHAIN: auto + run: make lint + - name: Unit tests run: go test ./internal/pipeline/ ./internal/controller/ ./api/... -race -coverprofile=unit-cover.out - - name: Envtest + - name: Envtest domain integration run: make test-envtest - name: Generated files are current run: git diff --exit-code + + kind-domain: + name: Kind StackRun domain E2E + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Install pinned Kind + run: | + curl -fsSL -o /tmp/kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 + echo "a6875aaea358acf0ac07786b1a6755d08fd640f4c79b7a2e46681cc13f49a04b /tmp/kind" | sha256sum -c - + chmod +x /tmp/kind + sudo mv /tmp/kind /usr/local/bin/kind + + - name: Reconcile sample StackRun in Kind + env: + CLUSTER_CI_GIT_REVISION: ${{ github.sha }} + CLUSTER_CI_GIT_URL: https://github.com/${{ github.repository }}.git + run: | + set -o pipefail + bash scripts/run-cluster-ci.sh \ + --skip-isolation --skip-phase2 --skip-newman 2>&1 | tee operator-kind-e2e.log + + - name: Collect operator failure diagnostics + if: failure() + run: | + kubectl get stack,stackrun,pipelinerun -A -o yaml > operator-resources.yaml 2>&1 || true + kubectl get pods,deployments -A -o wide > operator-workloads.txt 2>&1 || true + kubectl logs -n tekton-pipelines -l app=tekton-dag-operator \ + --all-containers=true --prefix=true > operator-controller.log 2>&1 || true + kubectl get events -A --sort-by=.lastTimestamp > operator-events.txt 2>&1 || true + + - name: Upload Kind E2E diagnostics + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: operator-kind-e2e-${{ github.sha }} + path: | + operator-kind-e2e.log + operator-resources.yaml + operator-workloads.txt + operator-controller.log + operator-events.txt + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/results-regression.yml b/.github/workflows/results-regression.yml new file mode 100644 index 0000000..f696279 --- /dev/null +++ b/.github/workflows/results-regression.yml @@ -0,0 +1,106 @@ +name: Tekton Results regression + +on: + workflow_dispatch: + schedule: + # Weekly strict Results/Postgres evidence on the default branch. + - cron: "19 9 * * 3" + +permissions: + contents: read + +concurrency: + group: results-regression-${{ github.ref }} + cancel-in-progress: false + +jobs: + strict-results: + name: Strict regression with Results DB + runs-on: ubuntu-latest + timeout-minutes: 180 + env: + RESULTS_ARTIFACT_DIR: ${{ github.workspace }}/artifacts/results + TEKTON_RESULTS_VERSION: v0.20.0 + KIND_VERSION: v0.27.0 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: "22" + cache: npm + cache-dependency-path: management-gui/frontend/package-lock.json + + - name: Install Kind and Newman + run: | + curl -fsSL -o /tmp/kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" + echo "a6875aaea358acf0ac07786b1a6755d08fd640f4c79b7a2e46681cc13f49a04b /tmp/kind" | sha256sum -c - + chmod +x /tmp/kind + sudo mv /tmp/kind /usr/local/bin/kind + npm install -g newman@6.2.1 + ./scripts/bootstrap-regression-venv.sh + + - name: Install cluster control plane + env: + CLUSTER_CI_GIT_REVISION: ${{ github.sha }} + CLUSTER_CI_GIT_URL: https://github.com/${{ github.repository }}.git + run: | + set -o pipefail + bash scripts/run-cluster-ci.sh --skip-isolation 2>&1 | tee cluster-setup.log + + - name: Install PostgreSQL and Tekton Results + run: | + set -o pipefail + { + bash scripts/install-postgres-kind.sh --ephemeral + bash scripts/install-tekton-results.sh + } 2>&1 | tee results-install.log + + - name: Run strict Results regression + run: | + set -o pipefail + bash scripts/run-regression-agent-full.sh 2>&1 | tee results-regression.log + + - name: Collect Results failure diagnostics + if: failure() + run: | + mkdir -p "$RESULTS_ARTIFACT_DIR" + kubectl get deployment,pod,service,secret -n tekton-pipelines -o wide \ + > "$RESULTS_ARTIFACT_DIR/control-plane.txt" 2>&1 || true + kubectl get pipelinerun,taskrun,stackrun -n tekton-pipelines -o yaml \ + > "$RESULTS_ARTIFACT_DIR/run-resources.yaml" 2>&1 || true + kubectl describe deployment tekton-results-api tekton-results-watcher \ + -n tekton-pipelines > "$RESULTS_ARTIFACT_DIR/results-deployments.txt" 2>&1 || true + kubectl logs -n tekton-pipelines deployment/tekton-results-api \ + --all-containers=true --prefix=true \ + > "$RESULTS_ARTIFACT_DIR/results-api.log" 2>&1 || true + kubectl logs -n tekton-pipelines deployment/tekton-results-watcher \ + --all-containers=true --prefix=true \ + > "$RESULTS_ARTIFACT_DIR/results-watcher.log" 2>&1 || true + kubectl logs -n tekton-pipelines -l app=tekton-results-postgres \ + --all-containers=true --prefix=true \ + > "$RESULTS_ARTIFACT_DIR/postgres.log" 2>&1 || true + kubectl get events -n tekton-pipelines --sort-by=.lastTimestamp \ + > "$RESULTS_ARTIFACT_DIR/events.txt" 2>&1 || true + + - name: Upload strict Results evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: results-regression-${{ github.sha }} + path: | + artifacts/results/ + cluster-setup.log + results-install.log + results-regression.log + if-no-files-found: warn + retention-days: 30 diff --git a/docs/REGRESSION.md b/docs/REGRESSION.md index 90b2a69..2fcd659 100644 --- a/docs/REGRESSION.md +++ b/docs/REGRESSION.md @@ -11,9 +11,13 @@ Do **not** confuse these: | Scope | What runs | Typical trigger | |-------|-----------|-----------------| | **Application PR** (`stack-pr-test` on an **app** repo) | Stack-defined tests only — e.g. that app’s Newman/Playwright/Artillery as declared in `stacks/*.yaml`, against the intercept build. | Every PR on the **application** repository (when webhooks/Tekton are wired). | -| **Platform regression** (`scripts/run-regression*.sh` on **this** repo) | **System / integration** tiers: Phase 1 + orchestrator + shared libs + GUI pytest, Playwright for **management-gui**, real **`stack-dag-verify`** PipelineRun, Newman against **orchestrator** API, optional Tekton Results, optional Kind E2E. | **PRs / `main`:** [`.github/workflows/local-regression.yml`](../.github/workflows/local-regression.yml) runs **`--local-only --require-lang-tests`**. **Nightly / dispatch / `v*` tags:** [`.github/workflows/cluster-regression.yml`](../.github/workflows/cluster-regression.yml) runs Playwright + Kind (`scripts/run-cluster-ci.sh`). Full intercept E2E remains **manual**. | +| **Platform regression** (`scripts/run-regression*.sh` on **this** repo) | **System / integration** tiers: Phase 1 + orchestrator + shared libs + GUI pytest, Playwright for **management-gui**, real **`stack-dag-verify`** PipelineRun, Newman against **orchestrator** API, optional Tekton Results, optional Kind E2E. | **PRs / `main`:** [`.github/workflows/local-regression.yml`](../.github/workflows/local-regression.yml) runs **`--local-only --require-lang-tests`**. **Nightly / dispatch / `v*` tags:** [`.github/workflows/cluster-regression.yml`](../.github/workflows/cluster-regression.yml) runs Playwright + Kind. **Weekly / dispatch:** [`intercept-e2e.yml`](../.github/workflows/intercept-e2e.yml) runs both intercept backends and [`results-regression.yml`](../.github/workflows/results-regression.yml) runs strict Results/Postgres verification. | -So: **not all tests run on every PR.** `--local-only` (including Java/PHP/operator) is PR-gated. Playwright, Newman, Phase 2, and Kind isolation measurements run on **cluster-regression** (nightly / `workflow_dispatch` / version tags), not on pull requests. App PRs run a narrower, stack-scoped test stage. +So: **not all tests run on every PR.** `--local-only` (including Java/PHP/operator) is PR-gated. Playwright, Newman, Phase 2, and Kind isolation measurements run on **cluster-regression** (nightly / `workflow_dispatch` / version tags), not on pull requests. The slower Telepresence and mirrord product paths run weekly and on dispatch. App PRs run a narrower, stack-scoped test stage. + +The existence of the intercept workflow is not proof that either backend is +currently healthy. Treat only a recent successful matrix job and its retained +traffic artifact as verification. **Streaming / timestamps:** use **`scripts/run-regression-stream.sh`** — same arguments, prefixes each line with `[HH:MM:SS]` and preserves the real exit code (plain `| while read` does not). @@ -41,9 +45,10 @@ So: **not all tests run on every PR.** `--local-only` (including Java/PHP/operat | **B — Browser** | Playwright in `management-gui/frontend` | Default; skip with `--local-only` or `--skip-playwright` | | **C — Tekton DAG pipeline** | [scripts/verify-dag-phase2.sh](../scripts/verify-dag-phase2.sh) — **`stack-dag-verify`** to **Succeeded** | **Auto** if `kubectl` works and `Pipeline/stack-dag-verify` exists in `NAMESPACE`. **Skipped** when [run-full-test-and-verify-results.sh](../scripts/run-full-test-and-verify-results.sh) will run (it already includes Phase 2). **Forced failure if missing** with `--require-dag-verify`. **Off** with `--skip-dag-verify` or `REGRESSION_DAG_VERIFY=skip`. | | **D — Cluster API** | Newman via [run-orchestrator-tests.sh](../scripts/run-orchestrator-tests.sh) `--all` | **Auto** if orchestrator `Service` exists and `newman` on `PATH`; **required** with `--cluster` | -| **E — Results + DB** | [run-full-test-and-verify-results.sh](../scripts/run-full-test-and-verify-results.sh) | **Auto** if `tekton-results-api` exists; **forced** with `--with-results-verify`; **off** with `--skip-results-verify` | +| **E — Results + DB** | [run-full-test-and-verify-results.sh](../scripts/run-full-test-and-verify-results.sh) | **Auto** if `tekton-results-api` exists; **forced** with `--with-results-verify`; **off** with `--skip-results-verify`; strict weekly/dispatch automation uses `run-regression-agent-full.sh` | | **F — GUI Postman** | [management-gui-tests.json](../tests/postman/management-gui-tests.json) vs `http://localhost:5000` | `--gui-newman` | | **G — Full Kind E2E** | [run-all-setup-and-test.sh](../scripts/run-all-setup-and-test.sh) | `--kind-e2e` | +| **H — Intercept product E2E** | [run-product-intercept-e2e.sh](../scripts/run-product-intercept-e2e.sh) via authenticated orchestrator API | Weekly/dispatch matrix in `intercept-e2e.yml`; requires repository secret `E2E_GIT_SSH_PRIVATE_KEY` with read access to application repos | ## Prerequisites @@ -95,6 +100,9 @@ chmod +x scripts/run-regression.sh # once, if needed # Full platform smoke (Kind + Tekton + intercepts + DB) — use sparingly ./scripts/run-regression.sh --local-only --kind-e2e + +# On an already prepared cluster: trigger -> StackRun -> operator -> PR PipelineRun +./scripts/run-product-intercept-e2e.sh --intercept-backend telepresence ``` Environment: @@ -108,8 +116,8 @@ Environment: 1. **Often (fast, no cluster):** `./scripts/run-regression.sh --local-only` — Phase 1 + pytest + vitest; good for frequent pushes; safe to wire into lightweight CI. 2. **System bar (cluster):** **`./scripts/run-regression.sh --cluster --require-dag-verify`** when you need a real **Succeeded** `stack-dag-verify` and orchestrator Newman — treat as **integration / system** work: before releases, after big platform changes, on a schedule, or when agents/docs require proof — **not** as “must pass on every GitHub PR” unless you explicitly configure that. -3. **With Tekton Results:** `--with-results-verify` or rely on **auto** when the API exists. -4. **Kind E2E:** `--kind-e2e` when changing bootstrap, intercepts, or Results integration (heavy; occasional). +3. **With Tekton Results:** `results-regression.yml` installs pinned Results/Postgres weekly and runs the strict agent entrypoint; locally use `--with-results-verify` or rely on **auto** when the API exists. +4. **Intercept E2E:** the weekly matrix exercises Telepresence and mirrord independently and retains StackRun, PipelineRun, TaskRun, pod-log, and test-traffic evidence for 30 days. After the tiers you care about are green, update [milestones/milestone-8.md](../milestones/milestone-8.md) and related testing docs. diff --git a/docs/SCRIPTS.md b/docs/SCRIPTS.md index 296561a..55d583b 100644 --- a/docs/SCRIPTS.md +++ b/docs/SCRIPTS.md @@ -44,7 +44,7 @@ Shared helpers live in [`scripts/common.sh`](../scripts/common.sh) (sourced by m | [`install-tekton-results.sh`](../scripts/install-tekton-results.sh) | Tekton Results components. | | [`install-postgres-kind.sh`](../scripts/install-postgres-kind.sh) | Postgres in Kind (Results / app DB). | | [`install-neo4j-kind.sh`](../scripts/install-neo4j-kind.sh) | Neo4j in Kind for graph features. | -| [`bootstrap-namespace.sh`](../scripts/bootstrap-namespace.sh) | Bootstrap namespace resources for a stack. | +| [`bootstrap-namespace.sh`](../scripts/bootstrap-namespace.sh) | Bootstrap namespace resources with least-privilege pipeline RBAC. `--cluster-admin` is an explicit disposable-cluster escape hatch. | | [`install-operator-webhook-kind.sh`](../scripts/install-operator-webhook-kind.sh) | Kind: TLS certs + Stack ValidatingWebhookConfiguration (`failurePolicy: Fail`). | --- diff --git a/docs/m7-mirrord-intercept-task.md b/docs/m7-mirrord-intercept-task.md index f4bf8c9..f14077a 100644 --- a/docs/m7-mirrord-intercept-task.md +++ b/docs/m7-mirrord-intercept-task.md @@ -41,7 +41,7 @@ Publish with the other build images: ./scripts/publish-build-images.sh ``` -The pipeline uses this image for the mirrord-proxy pods (param `mirrord-image` on the task, default `localhost:5001/tekton-dag-build-mirrord:latest`). +The pipeline uses this image for the mirrord-proxy pods (param `mirrord-image` on the task, default `localhost:5000/tekton-dag-build-mirrord:latest`). --- diff --git a/helm/tekton-dag/templates/rbac.yaml b/helm/tekton-dag/templates/rbac.yaml index 99552e9..8b4674f 100644 --- a/helm/tekton-dag/templates/rbac.yaml +++ b/helm/tekton-dag/templates/rbac.yaml @@ -58,9 +58,17 @@ rules: resources: ["ingresses", "networkpolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # Pipeline tasks inspect execution resources for status and cleanup. + # The orchestrator uses this ServiceAccount to create PipelineRuns. - apiGroups: ["tekton.dev"] resources: ["pipelineruns", "taskruns"] - verbs: ["get", "list", "watch", "patch", "delete"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] + # The orchestrator submits StackRuns and resolves Stack/Team configuration. + - apiGroups: ["tektondag.io"] + resources: ["stackruns"] + verbs: ["get", "list", "watch", "create"] + - apiGroups: ["tektondag.io"] + resources: ["stacks", "teams"] + verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/libs/tekton-dag-common/tests/test_m17_hardening.py b/libs/tekton-dag-common/tests/test_m17_hardening.py index 6f5e2f0..8a1e700 100644 --- a/libs/tekton-dag-common/tests/test_m17_hardening.py +++ b/libs/tekton-dag-common/tests/test_m17_hardening.py @@ -1,7 +1,11 @@ """Static acceptance checks for M17 production-hardening defaults.""" +import json +import shutil +import subprocess from pathlib import Path +import pytest import yaml ROOT = Path(__file__).resolve().parents[3] @@ -23,6 +27,135 @@ def test_pipeline_rbac_has_explicit_least_privilege_fallback(): assert "deployments/scale" in template +def _render_chart(*values): + helm = shutil.which("helm") + if helm is None: + pytest.skip("Helm is not installed") + # RBAC rendering is independent of packaged Stack/Team CRs. A clean + # checkout intentionally lacks those generated files until package.sh runs. + command = [ + helm, + "template", + "m17", + str(ROOT / "helm/tekton-dag"), + "--set", + "operator.enabled=false", + ] + for value in values: + command.extend(["--set", value]) + rendered = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ).stdout + return [document for document in yaml.safe_load_all(rendered) if document] + + +def test_helm_renders_least_privilege_pipeline_rbac_by_default(): + documents = _render_chart() + role = next( + document + for document in documents + if document["kind"] == "ClusterRole" + and document["metadata"]["name"] == "tekton-pr-sa-pipeline-tekton-pipelines" + ) + binding = next( + document + for document in documents + if document["kind"] == "ClusterRoleBinding" + and document["metadata"]["name"] + == "tekton-pr-sa-pipeline-tekton-pipelines" + ) + + assert binding["roleRef"]["name"] == role["metadata"]["name"] + assert all(rule.get("resources") != ["clusterrolebindings"] for rule in role["rules"]) + tekton_rule = next( + rule for rule in role["rules"] if rule.get("apiGroups") == ["tekton.dev"] + ) + assert "create" in tekton_rule["verbs"] + stackrun_rule = next( + rule + for rule in role["rules"] + if rule.get("apiGroups") == ["tektondag.io"] + and rule.get("resources") == ["stackruns"] + ) + assert "create" in stackrun_rule["verbs"] + assert not any( + document.get("roleRef", {}).get("name") == "cluster-admin" + for document in documents + ) + + +def test_helm_renders_cluster_admin_only_when_explicitly_enabled(): + documents = _render_chart("rbac.clusterAdmin=true") + + assert any( + document.get("roleRef", {}).get("name") == "cluster-admin" + for document in documents + ) + assert not any( + document["kind"] == "ClusterRole" + and "-pipeline-" in document["metadata"]["name"] + for document in documents + ) + + +def test_cluster_bootstrap_and_regression_enforce_least_privilege_rbac(): + bootstrap = (ROOT / "scripts/bootstrap-namespace.sh").read_text() + cluster_ci = (ROOT / "scripts/run-cluster-ci.sh").read_text() + + assert 'PIPELINE_RBAC_CLUSTER_ADMIN="${PIPELINE_RBAC_CLUSTER_ADMIN:-false}"' in bootstrap + assert "--cluster-admin)" in bootstrap + assert 'kubectl delete clusterrolebinding "tekton-pr-sa-admin-${NAMESPACE}"' in bootstrap + assert "tekton-pr-sa unexpectedly has cluster-admin-equivalent access" in cluster_ci + assert "tekton-pr-sa must not mutate Secrets" in cluster_ci + assert cluster_ci.index("install-operator-kind.sh") < cluster_ci.index( + "kubectl auth can-i create stackruns.tektondag.io" + ) + + +def test_newman_auth_negatives_override_collection_credentials(): + paths = ( + ROOT / "tests/postman/orchestrator-tests.json", + ROOT / "tests/postman/management-gui-tests.json", + ) + + for path in paths: + collection = json.loads(path.read_text()) + missing, invalid = collection["item"][0]["item"][:2] + assert "auth" not in missing + assert "auth" not in invalid + assert missing["request"]["auth"] == {"type": "noauth"} + assert invalid["request"]["auth"] == {"type": "noauth"} + invalid_headers = { + header["key"]: header["value"] + for header in invalid["request"]["header"] + } + assert invalid_headers["Authorization"] == "Bearer invalid-token" + + +def test_local_regression_installs_checksum_verified_helm(): + workflow = (ROOT / ".github/workflows/local-regression.yml").read_text() + + assert "HELM_VERSION: v4.3.0" in workflow + assert "86584a54def73570558f66f5111cc53dfed56689637ae32c1201205d494f54fb" in workflow + assert "sha256sum -c -" in workflow + + +def test_rbac_changes_trigger_strict_cluster_regression(): + workflow = (ROOT / ".github/workflows/cluster-regression.yml").read_text() + + assert "pull_request:" in workflow + assert '".github/workflows/cluster-regression.yml"' in workflow + assert '"helm/tekton-dag/**"' in workflow + assert '"scripts/bootstrap-namespace.sh"' in workflow + assert '"scripts/run-cluster-ci.sh"' in workflow + assert "Kind isolation-eval + Phase 2 + Newman" in workflow + assert 'github.event_name }}" == "pull_request"' in workflow + assert "extra+=(--skip-isolation)" in workflow + + def test_orchestrator_mutation_token_is_secret_backed_and_fail_closed(): values = yaml.safe_load((ROOT / "helm/tekton-dag/values.yaml").read_text()) api_auth = values["orchestrationService"]["apiAuth"] diff --git a/libs/tekton-dag-common/tests/test_m17_intercept_automation.py b/libs/tekton-dag-common/tests/test_m17_intercept_automation.py new file mode 100644 index 0000000..98fb63f --- /dev/null +++ b/libs/tekton-dag-common/tests/test_m17_intercept_automation.py @@ -0,0 +1,90 @@ +"""Static acceptance checks for the M17.3 intercept product path.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def test_intercept_workflow_has_explicit_backend_cadence_and_evidence(): + workflow = (ROOT / ".github/workflows/intercept-e2e.yml").read_text() + + assert "pull_request:" in workflow + assert '".github/workflows/intercept-e2e.yml"' in workflow + assert '"helm/tekton-dag/**"' in workflow + assert '"scripts/bootstrap-namespace.sh"' in workflow + assert "workflow_dispatch:" in workflow + assert "schedule:" in workflow + assert "backend: [telepresence, mirrord]" in workflow + assert "E2E_GIT_SSH_PRIVATE_KEY" in workflow + assert "Require E2E SSH credential" not in workflow + assert "public application repositories will use HTTPS" in workflow + assert "kubectl create secret generic ssh-key-secret" in workflow + assert "pod-security.kubernetes.io/enforce=privileged" in workflow + assert "run-product-intercept-e2e.sh" in workflow + assert "if: always()" in workflow + assert "actions/upload-artifact@" in workflow + assert "retention-days: 30" in workflow + # Workflow-level expressions are evaluated before matrix expansion. + assert "matrix." not in workflow.split("jobs:", 1)[0] + + +def test_product_script_covers_trigger_stackrun_tests_and_cleanup(): + script = (ROOT / "scripts/run-product-intercept-e2e.sh").read_text() + + assert "$API_URL/api/run" in script + assert "Authorization: Bearer $API_MUTATION_TOKEN" in script + assert "kubectl get stackrun" in script + assert "status.pipelineRunName" in script + assert "pipeline-results.json" in script + assert "tekton.dev/pipelineTask=run-tests" in script + assert 'pipeline_status" == "False"' in script + assert "pr-traffic-evidence.log" in script + assert "kubectl delete pipelinerun" in script + assert "kubectl delete stackrun" in script + + +def test_mirrord_image_uses_kind_registry_consistently(): + pipeline = (ROOT / "pipeline/stack-pr-pipeline.yaml").read_text() + task = (ROOT / "tasks/deploy-intercept-mirrord.yaml").read_text() + expected = "localhost:5000/tekton-dag-build-mirrord:latest" + + assert expected in pipeline + assert expected in task + assert "localhost:5001/tekton-dag-build-mirrord" not in pipeline + assert "localhost:5001/tekton-dag-build-mirrord" not in task + + +def test_app_clone_supports_public_https_without_ssh_key(): + task = (ROOT / "tasks/clone-app-repos.yaml").read_text() + + assert "CLONE_TRANSPORT=https" in task + assert 'URL="https://github.com/${REPO}.git"' in task + assert 'URL="git@github.com:${REPO}.git"' in task + assert "ssh-key workspace must contain" not in task + + +def test_tekton_install_allows_source_and_build_cache_pvcs(): + install = (ROOT / "scripts/install-tekton.sh").read_text() + + assert "kubectl patch configmap feature-flags -n tekton-pipelines" in install + assert '''-p '{"data":{"coschedule":"disabled"}}' '''.strip() in install + assert "rollout status deployment/tekton-pipelines-webhook" in install + assert "rollout status deployment/tekton-triggers-webhook" in install + assert ( + install.index("rollout status deployment/tekton-triggers-webhook") + < install.index('apply_with_retry -f "$TEKTON_TRIGGERS_INTERCEPTORS_URL"') + ) + assert 'apply_with_retry -f "$MILESTONE_DIR/tasks/"' in install + + +def test_compile_pipeline_defaults_are_valid_container_images(): + for name in ( + "stack-bootstrap-pipeline.yaml", + "stack-pr-pipeline.yaml", + "stack-merge-pipeline.yaml", + ): + pipeline = (ROOT / "pipeline" / name).read_text() + for image_param in ("npm", "maven", "gradle", "pip", "php"): + marker = f"- name: compile-image-{image_param}" + default = pipeline.split(marker, 1)[1].split("- name:", 1)[0] + assert 'default: "ubuntu:22.04"' in default diff --git a/libs/tekton-dag-common/tests/test_m17_operator_ci.py b/libs/tekton-dag-common/tests/test_m17_operator_ci.py new file mode 100644 index 0000000..7ccb88f --- /dev/null +++ b/libs/tekton-dag-common/tests/test_m17_operator_ci.py @@ -0,0 +1,37 @@ +"""Static acceptance checks for the M17.5 operator CI gate.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def test_operator_workflow_runs_pinned_quality_and_domain_jobs(): + workflow = (ROOT / ".github/workflows/operator.yml").read_text() + + assert "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" in workflow + assert "actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e" in workflow + assert "make lint" in workflow + assert "GOTOOLCHAIN: auto" in workflow + assert "make test-envtest" in workflow + assert workflow.count('"scripts/install-tekton.sh"') == 2 + assert "Kind StackRun domain E2E" in workflow + assert "v0.27.0/kind-linux-amd64" in workflow + assert "sha256sum -c -" in workflow + assert "--skip-isolation --skip-phase2 --skip-newman" in workflow + assert "actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f" in workflow + + +def test_operator_domain_integration_covers_m17_lifecycle_contracts(): + test = (ROOT / "operator/test/integration/stackrun_reconcile_test.go").read_text() + + assert 't.Run("creation status and idempotency"' in test + assert 't.Run("approval blocks then creates"' in test + assert 't.Run("continuation carries results and pvc"' in test + assert "TestInvalidStackRejectedByCRDSchema" in test + + +def test_tekton_install_retries_controller_owned_resource_apply_races(): + installer = (ROOT / "scripts/install-tekton.sh").read_text() + + assert "apply_with_retry()" in installer + assert 'apply_with_retry -f "$MILESTONE_DIR/pipeline/"' in installer diff --git a/libs/tekton-dag-common/tests/test_m17_results_automation.py b/libs/tekton-dag-common/tests/test_m17_results_automation.py new file mode 100644 index 0000000..a8cd021 --- /dev/null +++ b/libs/tekton-dag-common/tests/test_m17_results_automation.py @@ -0,0 +1,33 @@ +"""Static acceptance checks for the M17.4 strict Results regression.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def test_results_workflow_is_scheduled_strict_and_retains_diagnostics(): + workflow = (ROOT / ".github/workflows/results-regression.yml").read_text() + + assert "workflow_dispatch:" in workflow + assert "schedule:" in workflow + assert "run-regression-agent-full.sh" in workflow + assert "install-postgres-kind.sh --ephemeral" in workflow + assert "install-tekton-results.sh" in workflow + assert "if: failure()" in workflow + assert "results-api.log" in workflow + assert "results-watcher.log" in workflow + assert "postgres.log" in workflow + assert "if: always()" in workflow + assert "actions/upload-artifact@" in workflow + + +def test_results_installers_are_pinned_and_fail_closed(): + results = (ROOT / "scripts/install-tekton-results.sh").read_text() + postgres = (ROOT / "scripts/install-postgres-kind.sh").read_text() + + assert 'TEKTON_RESULTS_VERSION="${TEKTON_RESULTS_VERSION:-v0.20.0}"' in results + assert "/previous/${TEKTON_RESULTS_VERSION}/release.yaml" in results + assert "curl -fsSL" in results + assert "deployment/tekton-results-api" in results + assert "deployment/tekton-results-watcher" in results + assert "ERROR: PostgreSQL did not become ready" in postgres diff --git a/libs/tekton-dag-common/tests/test_pipeline_hook_taskrefs.py b/libs/tekton-dag-common/tests/test_pipeline_hook_taskrefs.py index 84de971..f48d70b 100644 --- a/libs/tekton-dag-common/tests/test_pipeline_hook_taskrefs.py +++ b/libs/tekton-dag-common/tests/test_pipeline_hook_taskrefs.py @@ -37,6 +37,17 @@ def test_hook_tasks_use_cluster_resolver(): found = set() for rel in PIPELINES: data = yaml.safe_load((ROOT / rel).read_text()) + hook_params = { + param["name"]: param.get("default") + for param in data["spec"].get("params") or [] + if param["name"] in { + "pre-build-task", + "post-build-task", + "pre-test-task", + "post-test-task", + } + } + assert all(value == "tekton-dag-hook-noop" for value in hook_params.values()) for task in _iter_pipeline_tasks(data.get("spec") or {}): if task.get("name") not in HOOK_TASKS: continue @@ -48,4 +59,18 @@ def test_hook_tasks_use_cluster_resolver(): params = {p["name"]: p.get("value") for p in ref.get("params") or []} assert params.get("kind") == "task" assert params.get("name", "").startswith("$(params.") + assert task["when"][0]["values"] == ["tekton-dag-hook-noop"] assert found == HOOK_TASKS + + +def test_hook_noop_sentinel_is_installed_and_accepts_all_hook_inputs(): + task = yaml.safe_load((ROOT / "tasks/tekton-dag-hook-noop.yaml").read_text()) + + assert task["metadata"]["name"] == "tekton-dag-hook-noop" + assert {param["name"] for param in task["spec"]["params"]} == { + "stack-json", + "build-apps", + "built-images", + "image-registry", + } + assert task["spec"]["workspaces"] == [{"name": "source", "optional": True}] diff --git a/milestones/milestone-17.md b/milestones/milestone-17.md index ad51ed3..0fa7582 100644 --- a/milestones/milestone-17.md +++ b/milestones/milestone-17.md @@ -30,7 +30,7 @@ regression criteria in `docs/AGENT-REGRESSION.md` are satisfied. - Acceptance: `helm template` assertions cover both defaults and opt-in cluster-admin; Phase 2, Newman, and intercept E2E pass with least privilege. -- [ ] **M17.2 Authenticate mutation APIs** +- [x] **M17.2 Authenticate mutation APIs** - Protect orchestrator `/api/run`, `/api/bootstrap`, `/api/reload`, and graph ingestion, plus Management GUI trigger/approval routes. - Health/readiness and read-only APIs remain independently configurable. @@ -52,7 +52,7 @@ regression criteria in `docs/AGENT-REGRESSION.md` are satisfied. ## P1 — CI gates and operator assurance -- [ ] **M17.5 Activate operator CI from root `.github/workflows/`** +- [x] **M17.5 Activate operator CI from root `.github/workflows/`** - Run formatting/lint, unit tests, controller envtest, and domain E2E. - Pin Kind and action dependencies. - Acceptance: StackRun→PipelineRun creation, status, approval, idempotency, @@ -158,4 +158,8 @@ regression criteria in `docs/AGENT-REGRESSION.md` are satisfied. |------|-------|----------|--------| | 2026-09-14 | Audit baseline | Local regression; Playwright; Go test/vet; coverage; latest cluster CI | Recorded | | 2026-09-14 | M17.2 mutation API authentication | Shared constant-time bearer check; 108 orchestrator, 68 GUI backend, and 61 common tests; frontend build; local regression exit 0 | Local green; Helm rendering and live-cluster Newman pending because this runner has no Helm or kubectl | +| 2026-09-14 | M17.3 intercept product automation | Weekly/manual Telepresence + mirrord matrix; authenticated trigger-to-StackRun runner; retained traffic diagnostics; 3 static acceptance tests; local regression exit 0 | Automation green locally; first live matrix run and SSH repository secret still required | +| 2026-09-14 | M17.4 strict Results automation | Weekly/manual pinned Results v0.20.0 + ephemeral Postgres workflow; fail-closed installers; failure diagnostics; 2 static acceptance tests; local regression exit 0 | Automation green locally; first live strict workflow run still required | +| 2026-09-14 | M17.2 mutation authentication acceptance | Strict Kind run 34874792600; least-privilege RBAC; Phase 2 passed; Newman missing/invalid/valid bearer paths; 38 assertions | Passed, zero Newman failures | +| 2026-09-14 | M17.5 operator CI acceptance | Root operator workflow; lint, unit, envtest, generated-file checks; Kind StackRun domain E2E | Passed in PR #42 | diff --git a/operator/.github/workflows/test-e2e.yml b/operator/.github/workflows/test-e2e.yml deleted file mode 100644 index b2eda8c..0000000 --- a/operator/.github/workflows/test-e2e.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: E2E Tests - -on: - push: - pull_request: - -jobs: - test-e2e: - name: Run on Ubuntu - runs-on: ubuntu-latest - steps: - - name: Clone the code - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install the latest version of kind - run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind - - - name: Verify kind installation - run: kind version - - - name: Create kind cluster - run: kind create cluster - - - name: Running Test e2e - run: | - go mod tidy - make test-e2e diff --git a/operator/.golangci.yml b/operator/.golangci.yml index 6b29746..2c5b741 100644 --- a/operator/.golangci.yml +++ b/operator/.golangci.yml @@ -1,33 +1,15 @@ +version: "2" run: - timeout: 5m allow-parallel-runners: true - -issues: - # don't skip warning about doc comments - # don't exclude the default set of lint - exclude-use-default: false - # restore some of the defaults - # (fill in the rest as needed) - exclude-rules: - - path: "api/*" - linters: - - lll - - path: "internal/*" - linters: - - dupl - - lll linters: - disable-all: true + default: none enable: + - copyloopvar - dupl - errcheck - - copyloopvar - ginkgolinter - goconst - gocyclo - - gofmt - - goimports - - gosimple - govet - ineffassign - lll @@ -36,12 +18,51 @@ linters: - prealloc - revive - staticcheck - - typecheck - unconvert - unparam - unused - -linters-settings: - revive: + settings: + revive: + rules: + - name: comment-spacings + exclusions: + generated: lax rules: - - name: comment-spacings + - linters: + - lll + path: api/* + - linters: + - dupl + - lll + path: internal/* + # Repeated map keys are clearer inline in unstructured manifest builders. + - linters: + - goconst + path: internal/pipeline/builder.go + # Test fixtures intentionally repeat domain values and group scenarios. + - linters: + - goconst + - gocyclo + path: _test\.go + # Kubebuilder scaffold helpers retain compatibility-oriented constructs. + - linters: + - staticcheck + path: test/utils/ + # mirrord is the product name, not a misspelling of "mirrored". + - linters: + - misspell + text: "`mirrord`" + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/operator/Makefile b/operator/Makefile index 2f902a2..e30f5c8 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -182,7 +182,7 @@ CONTROLLER_TOOLS_VERSION ?= v0.17.1 ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') -GOLANGCI_LINT_VERSION ?= v1.63.4 +GOLANGCI_LINT_VERSION ?= v2.13.2 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. @@ -210,7 +210,7 @@ $(ENVTEST): $(LOCALBIN) .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) - $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary diff --git a/operator/internal/controller/stack_controller.go b/operator/internal/controller/stack_controller.go index c1f5cf6..daac5e6 100644 --- a/operator/internal/controller/stack_controller.go +++ b/operator/internal/controller/stack_controller.go @@ -68,7 +68,7 @@ func (r *StackReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl stack.Status.MissingConfigMaps = missingC cond := metav1.Condition{ - Type: "Ready", + Type: conditionReady, Status: metav1.ConditionTrue, Reason: "Valid", Message: "Stack passed structural validation", diff --git a/operator/internal/controller/stackrun_controller.go b/operator/internal/controller/stackrun_controller.go index dc934bd..7a5b961 100644 --- a/operator/internal/controller/stackrun_controller.go +++ b/operator/internal/controller/stackrun_controller.go @@ -41,7 +41,8 @@ import ( const ( // Label marking PipelineRuns created for a StackRun. OwnerReference is not // set (orphan on StackRun delete) so Tekton Results history survives. - labelStackRun = "tektondag.io/stackrun" + labelStackRun = "tektondag.io/stackrun" + conditionReady = "Ready" pipelineStatusRetry = 15 * time.Second ) @@ -126,7 +127,7 @@ func (r *StackRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c latest.Status.ObservedGeneration = latest.Generation latest.Status.Phase = "Pending" meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Ready", + Type: conditionReady, Status: metav1.ConditionFalse, Reason: "PipelineRunCreated", Message: fmt.Sprintf("Created PipelineRun %s", prName), @@ -149,7 +150,7 @@ func (r *StackRunReconciler) pendingApproval(ctx context.Context, run *tektondag latest.Status.ObservedGeneration = latest.Generation latest.Status.Phase = "PendingApproval" meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Ready", + Type: conditionReady, Status: metav1.ConditionFalse, Reason: "PendingApproval", Message: "requireApproval is set; patch spec.approvedBy to create the PipelineRun", @@ -189,7 +190,7 @@ func (r *StackRunReconciler) syncPipelineStatus(ctx context.Context, run *tekton ready = metav1.ConditionTrue } meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Ready", + Type: conditionReady, Status: ready, Reason: reason, Message: msg, @@ -209,7 +210,7 @@ func (r *StackRunReconciler) fail(ctx context.Context, run *tektondagv1alpha1.St latest.Status.ObservedGeneration = latest.Generation latest.Status.Phase = "Error" meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Ready", + Type: conditionReady, Status: metav1.ConditionFalse, Reason: reason, Message: msg, diff --git a/operator/internal/controller/team_controller.go b/operator/internal/controller/team_controller.go index bcd164c..d552f8f 100644 --- a/operator/internal/controller/team_controller.go +++ b/operator/internal/controller/team_controller.go @@ -53,7 +53,7 @@ func (r *TeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. team.Status.ObservedGeneration = team.Generation team.Status.Ready = ready cond := metav1.Condition{ - Type: "Ready", + Type: conditionReady, Status: metav1.ConditionTrue, Reason: "Valid", Message: "Team spec accepted", diff --git a/operator/test/integration/stackrun_reconcile_test.go b/operator/test/integration/stackrun_reconcile_test.go new file mode 100644 index 0000000..5c85f49 --- /dev/null +++ b/operator/test/integration/stackrun_reconcile_test.go @@ -0,0 +1,305 @@ +package integration_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + tektondagv1alpha1 "github.com/jmjava/tekton-dag/operator/api/v1alpha1" + "github.com/jmjava/tekton-dag/operator/internal/controller" + "github.com/jmjava/tekton-dag/operator/internal/pipeline" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var ( + testEnv *envtest.Environment + testClient client.Client + testScheme *runtime.Scheme +) + +func TestMain(m *testing.M) { + testScheme = runtime.NewScheme() + if err := corev1.AddToScheme(testScheme); err != nil { + panic(err) + } + if err := tektondagv1alpha1.AddToScheme(testScheme); err != nil { + panic(err) + } + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "config", "crd", "bases"), + filepath.Join("testdata"), + }, + ErrorIfCRDPathMissing: true, + } + cfg, err := testEnv.Start() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "start envtest: %v\n", err) + os.Exit(1) + } + testClient, err = client.New(cfg, client.Options{Scheme: testScheme}) + if err != nil { + _ = testEnv.Stop() + panic(err) + } + + code := m.Run() + if err := testEnv.Stop(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "stop envtest: %v\n", err) + code = 1 + } + os.Exit(code) +} + +func TestStackRunDomainLifecycle(t *testing.T) { + ctx := context.Background() + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + GenerateName: "stackrun-domain-", + }} + if err := testClient.Create(ctx, namespace); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = testClient.Delete(ctx, namespace) }) + + stack := &tektondagv1alpha1.Stack{ + ObjectMeta: metav1.ObjectMeta{Name: "stack-one", Namespace: namespace.Name}, + Spec: tektondagv1alpha1.StackSpec{ + Name: "stack-one", + StackFile: "stacks/stack-one.yaml", + GitURL: "https://github.com/example/platform.git", + Apps: []tektondagv1alpha1.StackApp{ + {Name: "demo-fe", Repo: "example/demo-fe", Role: "frontend"}, + }, + }, + } + if err := testClient.Create(ctx, stack); err != nil { + t.Fatal(err) + } + + reconciler := &controller.StackRunReconciler{ + Client: testClient, + Scheme: testScheme, + } + requestFor := func(name string) ctrl.Request { + return ctrl.Request{NamespacedName: types.NamespacedName{ + Name: name, Namespace: namespace.Name, + }} + } + + t.Run("creation status and idempotency", func(t *testing.T) { + run := &tektondagv1alpha1.StackRun{ + ObjectMeta: metav1.ObjectMeta{Name: "bootstrap-domain", Namespace: namespace.Name}, + Spec: tektondagv1alpha1.StackRunSpec{ + Mode: tektondagv1alpha1.StackRunModeBootstrap, + StackRef: "stack-one", + }, + } + if err := testClient.Create(ctx, run); err != nil { + t.Fatal(err) + } + if _, err := reconciler.Reconcile(ctx, requestFor(run.Name)); err != nil { + t.Fatal(err) + } + + got := &tektondagv1alpha1.StackRun{} + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), got); err != nil { + t.Fatal(err) + } + if got.Status.PipelineRunName != run.Name || got.Status.Phase != "Pending" { + t.Fatalf("unexpected StackRun status: %#v", got.Status) + } + + pr := pipelineRun(run.Name, namespace.Name) + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), pr); err != nil { + t.Fatal(err) + } + if ref, _, _ := unstructured.NestedString(pr.Object, "spec", "pipelineRef", "name"); ref != "stack-bootstrap" { + t.Fatalf("pipelineRef=%q", ref) + } + + if _, err := reconciler.Reconcile(ctx, requestFor(run.Name)); err != nil { + t.Fatal(err) + } + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "tekton.dev", Version: "v1", Kind: "PipelineRunList", + }) + if err := testClient.List(ctx, list, client.InNamespace(namespace.Name)); err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 { + t.Fatalf("reconcile created %d PipelineRuns, want 1", len(list.Items)) + } + + if err := unstructured.SetNestedSlice(pr.Object, []any{ + map[string]any{"type": "Succeeded", "status": "True", "reason": "Succeeded"}, + }, "status", "conditions"); err != nil { + t.Fatal(err) + } + if err := testClient.Status().Update(ctx, pr); err != nil { + t.Fatal(err) + } + if _, err := reconciler.Reconcile(ctx, requestFor(run.Name)); err != nil { + t.Fatal(err) + } + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), got); err != nil { + t.Fatal(err) + } + if got.Status.Phase != "Succeeded" { + t.Fatalf("phase=%q, want Succeeded", got.Status.Phase) + } + }) + + t.Run("approval blocks then creates", func(t *testing.T) { + run := &tektondagv1alpha1.StackRun{ + ObjectMeta: metav1.ObjectMeta{Name: "promote-domain", Namespace: namespace.Name}, + Spec: tektondagv1alpha1.StackRunSpec{ + Mode: tektondagv1alpha1.StackRunModePromote, + StackRef: "stack-one", + ChangedApp: "demo-fe", + ReleaseVersion: "1.2.3", + TargetEnvironment: "staging", + RequireApproval: true, + }, + } + if err := testClient.Create(ctx, run); err != nil { + t.Fatal(err) + } + if _, err := reconciler.Reconcile(ctx, requestFor(run.Name)); err != nil { + t.Fatal(err) + } + got := &tektondagv1alpha1.StackRun{} + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), got); err != nil { + t.Fatal(err) + } + if got.Status.Phase != "PendingApproval" { + t.Fatalf("phase=%q", got.Status.Phase) + } + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), pipelineRun(run.Name, namespace.Name)); err == nil { + t.Fatal("PipelineRun exists before approval") + } + + got.Spec.ApprovedBy = "integration-test" + if err := testClient.Update(ctx, got); err != nil { + t.Fatal(err) + } + if _, err := reconciler.Reconcile(ctx, requestFor(run.Name)); err != nil { + t.Fatal(err) + } + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), pipelineRun(run.Name, namespace.Name)); err != nil { + t.Fatal(err) + } + }) + + t.Run("continuation carries results and pvc", func(t *testing.T) { + sourcePR := pipelineRun("source-pr", namespace.Name) + sourcePR.Object["spec"] = map[string]any{"params": []any{ + map[string]any{"name": "git-url", "value": "https://github.com/example/platform.git"}, + map[string]any{"name": "git-revision", "value": "main"}, + map[string]any{"name": "changed-app", "value": "demo-fe"}, + }} + if err := testClient.Create(ctx, sourcePR); err != nil { + t.Fatal(err) + } + sourceTR := taskRun("source-task", namespace.Name) + sourceTR.SetLabels(map[string]string{"tekton.dev/pipelineRun": sourcePR.GetName()}) + sourceTR.Object["spec"] = map[string]any{"workspaces": []any{ + map[string]any{ + "name": "shared-workspace", + "persistentVolumeClaim": map[string]any{"claimName": "source-pvc"}, + }, + }} + if err := testClient.Create(ctx, sourceTR); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedSlice(sourceTR.Object, []any{ + map[string]any{"name": "stack-json", "value": `{"apps":[]}`}, + map[string]any{"name": "built-images", "value": `{"demo-fe":"image:v1"}`}, + }, "status", "results"); err != nil { + t.Fatal(err) + } + if err := testClient.Status().Update(ctx, sourceTR); err != nil { + t.Fatal(err) + } + + run := &tektondagv1alpha1.StackRun{ + ObjectMeta: metav1.ObjectMeta{Name: "continue-domain", Namespace: namespace.Name}, + Spec: tektondagv1alpha1.StackRunSpec{ + Mode: tektondagv1alpha1.StackRunModePR, + ContinueFrom: sourcePR.GetName(), + }, + } + if err := testClient.Create(ctx, run); err != nil { + t.Fatal(err) + } + if _, err := reconciler.Reconcile(ctx, requestFor(run.Name)); err != nil { + t.Fatal(err) + } + pr := pipelineRun(run.Name, namespace.Name) + if err := testClient.Get(ctx, client.ObjectKeyFromObject(run), pr); err != nil { + t.Fatal(err) + } + ref, _, _ := unstructured.NestedString(pr.Object, "spec", "pipelineRef", "name") + if ref != "stack-pr-continue" { + t.Fatalf("pipelineRef=%q", ref) + } + workspaces, _, _ := unstructured.NestedSlice(pr.Object, "spec", "workspaces") + pvc, _, _ := unstructured.NestedString( + workspaces[0].(map[string]any), + "persistentVolumeClaim", + "claimName", + ) + if pvc != "source-pvc" { + t.Fatalf("workspace PVC=%q", pvc) + } + }) +} + +func TestInvalidStackRejectedByCRDSchema(t *testing.T) { + ctx := context.Background() + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "invalid-stack-"}} + if err := testClient.Create(ctx, namespace); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = testClient.Delete(ctx, namespace) }) + + invalid := &tektondagv1alpha1.Stack{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid", Namespace: namespace.Name}, + Spec: tektondagv1alpha1.StackSpec{ + Name: "invalid", + Apps: nil, + }, + } + if err := testClient.Create(ctx, invalid); err == nil { + t.Fatal("invalid Stack with no apps was admitted") + } +} + +func pipelineRun(name, namespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(pipeline.PipelineRunGVK) + obj.SetName(name) + obj.SetNamespace(namespace) + return obj +} + +func taskRun(name, namespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "tekton.dev", Version: "v1", Kind: "TaskRun", + }) + obj.SetName(name) + obj.SetNamespace(namespace) + return obj +} diff --git a/operator/test/integration/testdata/tekton.dev_pipelineruns.yaml b/operator/test/integration/testdata/tekton.dev_pipelineruns.yaml new file mode 100644 index 0000000..104d706 --- /dev/null +++ b/operator/test/integration/testdata/tekton.dev_pipelineruns.yaml @@ -0,0 +1,22 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: pipelineruns.tekton.dev +spec: + group: tekton.dev + names: + kind: PipelineRun + listKind: PipelineRunList + plural: pipelineruns + singular: pipelinerun + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} diff --git a/operator/test/integration/testdata/tekton.dev_taskruns.yaml b/operator/test/integration/testdata/tekton.dev_taskruns.yaml new file mode 100644 index 0000000..50ae5fd --- /dev/null +++ b/operator/test/integration/testdata/tekton.dev_taskruns.yaml @@ -0,0 +1,22 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: taskruns.tekton.dev +spec: + group: tekton.dev + names: + kind: TaskRun + listKind: TaskRunList + plural: taskruns + singular: taskrun + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} diff --git a/operator/test/utils/utils.go b/operator/test/utils/utils.go index 0488aa7..30bf603 100644 --- a/operator/test/utils/utils.go +++ b/operator/test/utils/utils.go @@ -24,7 +24,7 @@ import ( "os/exec" "strings" - . "github.com/onsi/ginkgo/v2" //nolint:golint,revive + . "github.com/onsi/ginkgo/v2" //nolint:revive ) const ( diff --git a/pipeline/stack-bootstrap-pipeline.yaml b/pipeline/stack-bootstrap-pipeline.yaml index 4f5d246..7692551 100644 --- a/pipeline/stack-bootstrap-pipeline.yaml +++ b/pipeline/stack-bootstrap-pipeline.yaml @@ -25,19 +25,19 @@ spec: default: "base-1" - name: compile-image-npm description: "Image for npm compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-maven description: "Image for Maven compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-gradle description: "Image for Gradle compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-pip description: "Image for pip compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-php description: "Image for Composer/PHP compile step" - default: "" + default: "ubuntu:22.04" - name: cache-repo description: "Kaniko cache repo. Empty disables cache." default: "" @@ -45,11 +45,11 @@ spec: description: "Max concurrent Kaniko pods for containerize. 0 = unlimited." default: "0" - name: pre-build-task - description: "Optional Tekton Task to run after clone, before compile. Empty = skip." - default: "" + description: "Optional Tekton Task to run after clone, before compile." + default: "tekton-dag-hook-noop" - name: post-build-task - description: "Optional Tekton Task to run after containerize, before deploy. Empty = skip." - default: "" + description: "Optional Tekton Task to run after containerize, before deploy." + default: "tekton-dag-hook-noop" workspaces: - name: shared-workspace - name: ssh-key @@ -123,7 +123,7 @@ spec: when: - input: $(params.pre-build-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] # Tekton v1.6+ validates taskRef.name as a DNS label before param # substitution, so $(params.*) cannot be the name field. Cluster # resolver takes the Task name as a param (skipped when empty). @@ -282,7 +282,7 @@ spec: when: - input: $(params.post-build-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: diff --git a/pipeline/stack-merge-pipeline.yaml b/pipeline/stack-merge-pipeline.yaml index bd07a79..9279428 100644 --- a/pipeline/stack-merge-pipeline.yaml +++ b/pipeline/stack-merge-pipeline.yaml @@ -37,25 +37,25 @@ spec: default: "{}" - name: compile-image-npm description: "Image for npm compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-maven description: "Image for Maven compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-gradle description: "Image for Gradle compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-pip description: "Image for pip compile step" - default: "" + default: "ubuntu:22.04" - name: compile-image-php description: "Image for Composer/PHP compile step" - default: "" + default: "ubuntu:22.04" - name: pre-build-task - description: "Optional Tekton Task to run after clone, before compile. Empty = skip." - default: "" + description: "Optional Tekton Task to run after clone, before compile." + default: "tekton-dag-hook-noop" - name: post-build-task - description: "Optional Tekton Task to run after containerize, before tag-release. Empty = skip." - default: "" + description: "Optional Tekton Task to run after containerize, before tag-release." + default: "tekton-dag-hook-noop" workspaces: - name: shared-workspace @@ -156,7 +156,7 @@ spec: when: - input: $(params.pre-build-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: @@ -308,7 +308,7 @@ spec: when: - input: $(params.post-build-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: diff --git a/pipeline/stack-pr-pipeline.yaml b/pipeline/stack-pr-pipeline.yaml index 4b6b323..b81efea 100644 --- a/pipeline/stack-pr-pipeline.yaml +++ b/pipeline/stack-pr-pipeline.yaml @@ -42,22 +42,22 @@ spec: default: "{}" - name: compile-image-npm description: "Pre-built image for npm compile" - default: "" + default: "ubuntu:22.04" - name: compile-image-maven description: "Pre-built image for Maven compile" - default: "" + default: "ubuntu:22.04" - name: compile-image-gradle description: "Pre-built image for Gradle compile" - default: "" + default: "ubuntu:22.04" - name: compile-image-pip description: "Pre-built image for pip compile" - default: "" + default: "ubuntu:22.04" - name: compile-image-php description: "Pre-built image for Composer/PHP compile" - default: "" + default: "ubuntu:22.04" - name: compile-image-mirrord description: "Pre-built image for mirrord proxy pods (M7)" - default: "" + default: "localhost:5000/tekton-dag-build-mirrord:latest" - name: intercept-backend description: "Which intercept backend to use: telepresence (default) or mirrord" default: "telepresence" @@ -77,17 +77,17 @@ spec: description: "Max concurrent Kaniko pods for containerize. 0 = unlimited." default: "0" - name: pre-build-task - description: "Optional Tekton Task name to run after clone, before compile (e.g. code-gen, license-scan). Empty = skip." - default: "" + description: "Optional Tekton Task name to run after clone, before compile (e.g. code-gen, license-scan)." + default: "tekton-dag-hook-noop" - name: post-build-task - description: "Optional Tekton Task name to run after containerize, before deploy (e.g. image-scan, SBOM). Empty = skip." - default: "" + description: "Optional Tekton Task name to run after containerize, before deploy (e.g. image-scan, SBOM)." + default: "tekton-dag-hook-noop" - name: pre-test-task - description: "Optional Tekton Task name to run after deploy, before tests (e.g. seed-data). Empty = skip." - default: "" + description: "Optional Tekton Task name to run after deploy, before tests (e.g. seed-data)." + default: "tekton-dag-hook-noop" - name: post-test-task - description: "Optional Tekton Task name to run in finally block after tests (e.g. slack-notify). Empty = skip." - default: "" + description: "Optional Tekton Task name to run in finally block after tests (e.g. slack-notify)." + default: "tekton-dag-hook-noop" - name: max-retries description: > Preferred retry count for infrastructure-sensitive tasks (compile/containerize). @@ -197,7 +197,7 @@ spec: when: - input: $(params.pre-build-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: @@ -362,7 +362,7 @@ spec: when: - input: $(params.post-build-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: @@ -483,7 +483,7 @@ spec: when: - input: $(params.pre-test-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: @@ -550,7 +550,7 @@ spec: when: - input: $(params.post-test-task) operator: notin - values: [""] + values: ["tekton-dag-hook-noop"] taskRef: resolver: cluster params: diff --git a/scripts/bootstrap-namespace.sh b/scripts/bootstrap-namespace.sh index 84877f7..e631d08 100755 --- a/scripts/bootstrap-namespace.sh +++ b/scripts/bootstrap-namespace.sh @@ -17,6 +17,7 @@ shift 2>/dev/null || true SSH_KEY_PATH="${SSH_KEY_PATH:-$HOME/.ssh/id_ed25519}" GITHUB_TOKEN="${GITHUB_TOKEN:-}" WEBHOOK_SECRET="${WEBHOOK_SECRET:-}" +PIPELINE_RBAC_CLUSTER_ADMIN="${PIPELINE_RBAC_CLUSTER_ADMIN:-false}" GIT_CLONE_URL="${TEKTON_GIT_CLONE_URL:-https://raw.githubusercontent.com/tektoncd/catalog/main/task/git-clone/0.9/git-clone.yaml}" while [[ $# -gt 0 ]]; do @@ -24,10 +25,16 @@ while [[ $# -gt 0 ]]; do --ssh-key) SSH_KEY_PATH="$2"; shift 2 ;; --github-token) GITHUB_TOKEN="$2"; shift 2 ;; --webhook-secret) WEBHOOK_SECRET="$2"; shift 2 ;; + --cluster-admin) PIPELINE_RBAC_CLUSTER_ADMIN=true; shift ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done +case "$PIPELINE_RBAC_CLUSTER_ADMIN" in + true|false) ;; + *) die "PIPELINE_RBAC_CLUSTER_ADMIN must be true or false" ;; +esac + need kubectl echo "==============================================" @@ -46,11 +53,73 @@ kubectl label namespace "$NAMESPACE" pod-security.kubernetes.io/warn=privileged echo " Creating ServiceAccount tekton-pr-sa..." kubectl create serviceaccount tekton-pr-sa -n "$NAMESPACE" 2>/dev/null || true -# 3. RBAC — cluster-admin for the SA (pipeline needs to deploy to staging, create intercepts, etc.) -echo " Creating ClusterRoleBinding..." -kubectl create clusterrolebinding "tekton-pr-sa-admin-${NAMESPACE}" \ - --clusterrole=cluster-admin \ - --serviceaccount="$NAMESPACE:tekton-pr-sa" 2>/dev/null || true +# 3. RBAC — least privilege by default. Cluster-admin is an explicit escape +# hatch for disposable local clusters only. +if [[ "$PIPELINE_RBAC_CLUSTER_ADMIN" == "true" ]]; then + echo " WARNING: granting cluster-admin to tekton-pr-sa (explicit escape hatch)" + kubectl create clusterrolebinding "tekton-pr-sa-admin-${NAMESPACE}" \ + --clusterrole=cluster-admin \ + --serviceaccount="$NAMESPACE:tekton-pr-sa" \ + --dry-run=client -o yaml | kubectl apply -f - +else + echo " Applying least-privilege pipeline RBAC..." + # Remove the legacy default binding when upgrading an existing namespace. + kubectl delete clusterrolebinding "tekton-pr-sa-admin-${NAMESPACE}" \ + --ignore-not-found + kubectl apply -f - </dev/null; then - echo " PostgreSQL is ready." -else - echo " WARN: PostgreSQL pod not ready (PVC may be Pending; install a StorageClass or use --storage-class)." - echo " Continuing; Tekton Results may fail until Postgres is up." +if ! kubectl wait --for=condition=Ready pod -l app=tekton-results-postgres -n "$NAMESPACE" --timeout=180s; then + echo " ERROR: PostgreSQL did not become ready." >&2 + echo " For Kind without a default StorageClass, re-run with --ephemeral." >&2 + kubectl get pod,pvc -l app=tekton-results-postgres -n "$NAMESPACE" -o wide >&2 || true + exit 1 fi +echo " PostgreSQL is ready." echo "" echo " Done. PostgreSQL (or PVC) is in $NAMESPACE." @@ -73,5 +74,5 @@ echo " Database: tekton-results" echo "" echo " Next (optional): install Tekton Results to persist pipeline/task run history:" echo " 1. Create TLS secret for the Results API (see Tekton Results install docs)." -echo " 2. kubectl apply -f https://storage.googleapis.com/tekton-releases/results/latest/release.yaml" +echo " 2. Run ./scripts/install-tekton-results.sh (pinned Tekton Results release)." echo "" diff --git a/scripts/install-tekton-results.sh b/scripts/install-tekton-results.sh index c6006d1..e9af6c6 100755 --- a/scripts/install-tekton-results.sh +++ b/scripts/install-tekton-results.sh @@ -7,7 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -RESULTS_RELEASE="${RESULTS_RELEASE:-https://storage.googleapis.com/tekton-releases/results/latest/release.yaml}" +# Pinned LTS release. Override only for an intentional compatibility test. +TEKTON_RESULTS_VERSION="${TEKTON_RESULTS_VERSION:-v0.20.0}" +RESULTS_RELEASE="${RESULTS_RELEASE:-https://infra.tekton.dev/tekton-releases/results/previous/${TEKTON_RESULTS_VERSION}/release.yaml}" API_CN="tekton-results-api-service.${NAMESPACE}.svc.cluster.local" need kubectl @@ -41,7 +43,7 @@ fi # Apply release but exclude the release's Postgres (StatefulSet, Service, ConfigMap) so we use our own Postgres echo " Fetching Tekton Results release and excluding its Postgres (using our Postgres)..." TMP_RELEASE=$(mktemp) -curl -sL "$RESULTS_RELEASE" -o "$TMP_RELEASE" +curl -fsSL "$RESULTS_RELEASE" -o "$TMP_RELEASE" yq eval-all ' select( ( (.kind == "StatefulSet" and .metadata.name == "tekton-results-postgres") or @@ -53,8 +55,8 @@ yq eval-all ' rm -f "$TMP_RELEASE" echo " Waiting for Results API and Watcher to be ready..." -kubectl wait --for=condition=Available deployment/tekton-results-api -n "$NAMESPACE" --timeout=120s 2>/dev/null || echo " (API may still be rolling out.)" -kubectl wait --for=condition=Available deployment/tekton-results-watcher -n "$NAMESPACE" --timeout=120s 2>/dev/null || echo " (Watcher may still be rolling out.)" +kubectl wait --for=condition=Available deployment/tekton-results-api -n "$NAMESPACE" --timeout=180s +kubectl wait --for=condition=Available deployment/tekton-results-watcher -n "$NAMESPACE" --timeout=180s # Grant default SA list/get on Results API so verify-results-in-db.sh can list results (watcher has create/update only) kubectl create clusterrolebinding tekton-results-readonly-default \ diff --git a/scripts/install-tekton.sh b/scripts/install-tekton.sh index 8d3dbc1..92410b2 100755 --- a/scripts/install-tekton.sh +++ b/scripts/install-tekton.sh @@ -19,6 +19,23 @@ TEKTON_GIT_CLONE_URL="${TEKTON_GIT_CLONE_URL:-https://raw.githubusercontent.com/ need kubectl +apply_with_retry() { + local attempts=3 + local attempt + + for attempt in $(seq 1 "$attempts"); do + if kubectl apply "$@"; then + return 0 + fi + if [ "$attempt" -lt "$attempts" ]; then + echo " kubectl apply raced with a controller; retrying ($attempt/$attempts)..." >&2 + sleep "$attempt" + fi + done + + return 1 +} + echo "==============================================" echo " Install Tekton (Pipelines + git-clone + stack tasks/pipelines)" echo " Pipelines: ${TEKTON_PIPELINE_VERSION} Triggers: ${TEKTON_TRIGGERS_VERSION}" @@ -30,20 +47,30 @@ kubectl get namespace "$NAMESPACE" &>/dev/null || kubectl create namespace "$NAM # 1. Tekton Pipelines (kubectl apply is idempotent) echo " Installing Tekton Pipelines..." kubectl apply -f "$TEKTON_PIPELINE_URL" +# Compile tasks bind the run-scoped source PVC and the persistent build-cache +# PVC. Tekton's default "workspaces" coscheduling mode rejects any TaskRun +# with more than one PVC before creating its Pod. +echo " Allowing TaskRuns to bind source and build-cache PVCs..." +kubectl patch configmap feature-flags -n tekton-pipelines --type merge \ + -p '{"data":{"coschedule":"disabled"}}' # Relax Pod Security for the target namespace (Kind enforces restricted; catalog/git-clone task pods need it) echo " Configuring namespace $NAMESPACE for Pod Security (Kind/local clusters)..." kubectl label namespace "$NAMESPACE" pod-security.kubernetes.io/enforce=privileged --overwrite 2>/dev/null || true kubectl label namespace "$NAMESPACE" pod-security.kubernetes.io/audit=privileged --overwrite 2>/dev/null || true kubectl label namespace "$NAMESPACE" pod-security.kubernetes.io/warn=privileged --overwrite 2>/dev/null || true echo " Waiting for Tekton Pipelines to be ready..." -kubectl wait --for=condition=Ready pods -l app.kubernetes.io/part-of=tekton-pipelines -n "$NAMESPACE" --timeout=120s 2>/dev/null || true +kubectl rollout status deployment/tekton-pipelines-controller -n tekton-pipelines --timeout=120s +kubectl rollout status deployment/tekton-pipelines-webhook -n tekton-pipelines --timeout=120s +kubectl rollout status deployment/tekton-pipelines-remote-resolvers -n tekton-pipelines-resolvers --timeout=120s # 2. Tekton Triggers (required for pipeline/triggers.yaml — EventListener, TriggerBinding, TriggerTemplate) echo " Installing Tekton Triggers..." kubectl apply -f "$TEKTON_TRIGGERS_URL" -kubectl apply -f "$TEKTON_TRIGGERS_INTERCEPTORS_URL" echo " Waiting for Tekton Triggers to be ready..." -kubectl wait --for=condition=Ready pods -l app.kubernetes.io/part-of=tekton-triggers -n "$NAMESPACE" --timeout=120s 2>/dev/null || true +kubectl rollout status deployment/tekton-triggers-controller -n tekton-pipelines --timeout=120s +kubectl rollout status deployment/tekton-triggers-webhook -n tekton-pipelines --timeout=120s +apply_with_retry -f "$TEKTON_TRIGGERS_INTERCEPTORS_URL" +kubectl rollout status deployment/tekton-triggers-core-interceptors -n tekton-pipelines --timeout=120s # 3. git-clone task (into target namespace so our pipelines can reference it) echo " Installing git-clone task..." @@ -52,8 +79,10 @@ kubectl apply -f "$TEKTON_GIT_CLONE_URL" -n "$NAMESPACE" 2>/dev/null || \ # 4. This repo's tasks and pipelines (kubectl apply is idempotent; triggers apply now that Triggers is installed) echo " Applying stack tasks and pipelines..." -kubectl apply -f "$MILESTONE_DIR/tasks/" -n "$NAMESPACE" -kubectl apply -f "$MILESTONE_DIR/pipeline/" -n "$NAMESPACE" +apply_with_retry -f "$MILESTONE_DIR/tasks/" -n "$NAMESPACE" +# EventListener reconciliation creates el-* Services. It can race the explicit +# Service in triggers.yaml between kubectl's read and create operations. +apply_with_retry -f "$MILESTONE_DIR/pipeline/" -n "$NAMESPACE" echo "" echo " Done. For full PR pipeline (intercepts), also install the Traffic Manager:" diff --git a/scripts/run-cluster-ci.sh b/scripts/run-cluster-ci.sh index 4d78019..9c3d7ca 100755 --- a/scripts/run-cluster-ci.sh +++ b/scripts/run-cluster-ci.sh @@ -104,6 +104,24 @@ kubectl wait --for=condition=Ready pod -l app.kubernetes.io/part-of=tekton-trigg echo "" echo ">>> Namespace bootstrap (SA + RBAC)" bash "$SCRIPT_DIR/bootstrap-namespace.sh" "$NAMESPACE" +pipeline_subject="system:serviceaccount:${NAMESPACE}:tekton-pr-sa" +if [[ "$(kubectl auth can-i '*' '*' --as="$pipeline_subject" 2>/dev/null || true)" != "no" ]]; then + die "tekton-pr-sa unexpectedly has cluster-admin-equivalent access" +fi +for permission in \ + "create deployments.apps" \ + "create pods" \ + "get secrets" \ + "create pipelineruns.tekton.dev"; do + read -r verb resource <<<"$permission" + if [[ "$(kubectl auth can-i "$verb" "$resource" --as="$pipeline_subject" 2>/dev/null || true)" != "yes" ]]; then + die "tekton-pr-sa lacks required permission: $permission" + fi +done +if [[ "$(kubectl auth can-i create secrets --as="$pipeline_subject" 2>/dev/null || true)" != "no" ]]; then + die "tekton-pr-sa must not mutate Secrets" +fi +echo " OK: tekton-pr-sa uses least-privilege pipeline RBAC" if [[ "$WITH_OPERATOR" == "true" ]]; then echo "" @@ -113,6 +131,9 @@ if [[ "$WITH_OPERATOR" == "true" ]]; then sample_args+=(--with-sample-run) fi bash "$SCRIPT_DIR/install-operator-kind.sh" "${sample_args[@]}" + if [[ "$(kubectl auth can-i create stackruns.tektondag.io --as="$pipeline_subject" 2>/dev/null || true)" != "yes" ]]; then + die "tekton-pr-sa lacks required permission: create stackruns.tektondag.io" + fi fi if [[ "$SKIP_PHASE2" != "true" ]]; then diff --git a/scripts/run-product-intercept-e2e.sh b/scripts/run-product-intercept-e2e.sh new file mode 100755 index 0000000..4e0bb7b --- /dev/null +++ b/scripts/run-product-intercept-e2e.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Exercise the production trigger path: +# orchestrator -> StackRun -> operator -> PR PipelineRun -> intercept tests -> cleanup. +# +# The cluster, operator, orchestrator, Tasks/Pipelines, build images, registry, +# and SSH clone Secret must already be installed. +set -euo pipefail +[ -z "${BASH_VERSION:-}" ] && exec bash "$0" "$@" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +INTERCEPT_BACKEND="${INTERCEPT_BACKEND:-telepresence}" +STACK_FILE="${STACK_FILE:-stack-one.yaml}" +CHANGED_APP="${CHANGED_APP:-demo-fe}" +PR_NUMBER="${PR_NUMBER:-900001}" +API_URL="${ORCHESTRATOR_URL:-}" +ARTIFACT_DIR="${E2E_ARTIFACT_DIR:-$REPO_ROOT/artifacts/intercept-e2e}" +TIMEOUT="${INTERCEPT_E2E_TIMEOUT:-1800}" +POLL_INTERVAL="${INTERCEPT_E2E_POLL_INTERVAL:-10}" +KEEP_RESOURCES="${KEEP_E2E_RESOURCES:-false}" +PORT_FORWARD_PID="" +STACKRUNS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --intercept-backend) INTERCEPT_BACKEND="$2"; shift 2 ;; + --stack) STACK_FILE="$2"; shift 2 ;; + --changed-app) CHANGED_APP="$2"; shift 2 ;; + --pr) PR_NUMBER="$2"; shift 2 ;; + --artifact-dir) ARTIFACT_DIR="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --keep-resources) KEEP_RESOURCES=true; shift ;; + --help|-h) + sed -n '2,6p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) die "Unknown option: $1" ;; + esac +done + +[[ "$INTERCEPT_BACKEND" == "telepresence" || "$INTERCEPT_BACKEND" == "mirrord" ]] \ + || die "--intercept-backend must be telepresence or mirrord" +[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || die "--pr must be numeric" + +need kubectl +need curl +need jq +mkdir -p "$ARTIFACT_DIR" + +resolve_api_token() { + if [[ -n "${API_MUTATION_TOKEN:-}" ]]; then + return + fi + API_MUTATION_TOKEN="$( + kubectl get secret tekton-dag-api-auth -n "$NAMESPACE" \ + -o jsonpath='{.data.token}' 2>/dev/null | base64 --decode + )" + [[ -n "$API_MUTATION_TOKEN" ]] \ + || die "API_MUTATION_TOKEN is unset and secret/tekton-dag-api-auth has no token" +} + +start_port_forward() { + if [[ -n "$API_URL" ]]; then + return + fi + local port="${ORCHESTRATOR_E2E_PORT:-19091}" + kubectl port-forward -n "$NAMESPACE" svc/tekton-dag-orchestrator "$port:8080" \ + >"$ARTIFACT_DIR/port-forward.log" 2>&1 & + PORT_FORWARD_PID=$! + API_URL="http://127.0.0.1:$port" + for _ in $(seq 1 30); do + if curl -fsS "$API_URL/readyz" >"$ARTIFACT_DIR/readyz.json" 2>/dev/null; then + return + fi + sleep 1 + done + die "orchestrator port-forward did not become ready" +} + +collect_run_evidence() { + local stackrun="$1" prefix="$2" pipeline_run + kubectl get stackrun "$stackrun" -n "$NAMESPACE" -o yaml \ + >"$ARTIFACT_DIR/$prefix-stackrun.yaml" 2>&1 || true + pipeline_run="$(kubectl get stackrun "$stackrun" -n "$NAMESPACE" \ + -o jsonpath='{.status.pipelineRunName}' 2>/dev/null || true)" + [[ -n "$pipeline_run" ]] || return + + kubectl get pipelinerun "$pipeline_run" -n "$NAMESPACE" -o yaml \ + >"$ARTIFACT_DIR/$prefix-pipelinerun.yaml" 2>&1 || true + kubectl get pipelinerun "$pipeline_run" -n "$NAMESPACE" -o json 2>/dev/null \ + | jq '{ + name: .metadata.name, + condition: (.status.conditions[0] // {}), + results: (.status.results // .status.pipelineResults // []) + }' >"$ARTIFACT_DIR/$prefix-pipeline-results.json" || true + kubectl get taskrun -n "$NAMESPACE" -l "tekton.dev/pipelineRun=$pipeline_run" -o yaml \ + >"$ARTIFACT_DIR/$prefix-taskruns.yaml" 2>&1 || true + kubectl logs -n "$NAMESPACE" -l "tekton.dev/pipelineRun=$pipeline_run" \ + --all-containers=true --prefix=true \ + >"$ARTIFACT_DIR/$prefix-pod-logs.txt" 2>&1 || true +} + +cleanup() { + local exit_code=$? + for i in "${!STACKRUNS[@]}"; do + collect_run_evidence "${STACKRUNS[$i]}" "run-$((i + 1))" + done + kubectl get stackrun,pipelinerun,taskrun -n "$NAMESPACE" -o wide \ + >"$ARTIFACT_DIR/final-resources.txt" 2>&1 || true + kubectl get pods,deployments,services -n staging -o wide \ + >"$ARTIFACT_DIR/final-staging-resources.txt" 2>&1 || true + + if [[ "$KEEP_RESOURCES" != "true" ]]; then + for stackrun in "${STACKRUNS[@]}"; do + pipeline_run="$(kubectl get stackrun "$stackrun" -n "$NAMESPACE" \ + -o jsonpath='{.status.pipelineRunName}' 2>/dev/null || true)" + [[ -z "$pipeline_run" ]] || kubectl delete pipelinerun "$pipeline_run" \ + -n "$NAMESPACE" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true + kubectl delete stackrun "$stackrun" -n "$NAMESPACE" \ + --ignore-not-found=true --wait=false >/dev/null 2>&1 || true + done + kubectl delete pod -n staging -l app=mirrord --ignore-not-found=true \ + --wait=false >/dev/null 2>&1 || true + kubectl delete pod -n staging -l app=mirrord-proxy --ignore-not-found=true \ + --wait=false >/dev/null 2>&1 || true + fi + [[ -z "$PORT_FORWARD_PID" ]] || kill "$PORT_FORWARD_PID" >/dev/null 2>&1 || true + exit "$exit_code" +} +trap cleanup EXIT + +trigger_run() { + local mode="$1" payload="$2" response stackrun + response="$(curl -fsS -X POST "$API_URL/api/run" \ + -H "Authorization: Bearer $API_MUTATION_TOKEN" \ + -H "Content-Type: application/json" \ + --data "$payload")" + printf '%s\n' "$response" >"$ARTIFACT_DIR/trigger-$mode.json" + stackrun="$(jq -er '.stackrun' <<<"$response")" + STACKRUNS+=("$stackrun") + TRIGGERED_STACKRUN="$stackrun" +} + +wait_for_stackrun() { + local stackrun="$1" label="$2" elapsed=0 phase="" pipeline_run="" + local pipeline_status="" pipeline_reason="" + while (( elapsed < TIMEOUT )); do + phase="$(kubectl get stackrun "$stackrun" -n "$NAMESPACE" \ + -o jsonpath='{.status.phase}' 2>/dev/null || true)" + pipeline_run="$(kubectl get stackrun "$stackrun" -n "$NAMESPACE" \ + -o jsonpath='{.status.pipelineRunName}' 2>/dev/null || true)" + echo " $label: phase=${phase:-Pending} pipelineRun=${pipeline_run:-Pending} elapsed=${elapsed}s" + case "$phase" in + Succeeded|Completed) return 0 ;; + Failed|Cancelled|TimedOut) + collect_run_evidence "$stackrun" "$label" + die "$label StackRun $stackrun failed with phase $phase" + ;; + esac + if [[ -n "$pipeline_run" ]]; then + pipeline_status="$(kubectl get pipelinerun "$pipeline_run" -n "$NAMESPACE" \ + -o jsonpath='{.status.conditions[?(@.type=="Succeeded")].status}' 2>/dev/null || true)" + pipeline_reason="$(kubectl get pipelinerun "$pipeline_run" -n "$NAMESPACE" \ + -o jsonpath='{.status.conditions[?(@.type=="Succeeded")].reason}' 2>/dev/null || true)" + if [[ "$pipeline_status" == "False" ]]; then + collect_run_evidence "$stackrun" "$label" + die "$label PipelineRun $pipeline_run failed with reason ${pipeline_reason:-Unknown}" + fi + fi + sleep "$POLL_INTERVAL" + elapsed=$((elapsed + POLL_INTERVAL)) + done + collect_run_evidence "$stackrun" "$label" + die "$label StackRun $stackrun timed out after ${TIMEOUT}s" +} + +verify_pr_evidence() { + local stackrun="$1" pipeline_run run_tests + pipeline_run="$(kubectl get stackrun "$stackrun" -n "$NAMESPACE" \ + -o jsonpath='{.status.pipelineRunName}')" + run_tests="$(kubectl get taskrun -n "$NAMESPACE" \ + -l "tekton.dev/pipelineRun=$pipeline_run,tekton.dev/pipelineTask=run-tests" \ + -o json)" + jq -e '.items | length > 0' <<<"$run_tests" >/dev/null \ + || die "PR PipelineRun has no run-tests TaskRun traffic evidence" + jq -e 'all(.items[].status.conditions[0].status; . == "True")' \ + <<<"$run_tests" >/dev/null \ + || die "PR PipelineRun run-tests TaskRun did not succeed" + kubectl logs -n "$NAMESPACE" -l \ + "tekton.dev/pipelineRun=$pipeline_run,tekton.dev/pipelineTask=run-tests" \ + --all-containers=true --prefix=true \ + >"$ARTIFACT_DIR/pr-traffic-evidence.log" + [[ -s "$ARTIFACT_DIR/pr-traffic-evidence.log" ]] \ + || die "run-tests traffic evidence log is empty" +} + +resolve_api_token +start_port_forward + +echo ">>> Trigger bootstrap through authenticated orchestrator API" +bootstrap_payload="$(jq -nc --arg stack "stacks/$STACK_FILE" \ + '{mode:"bootstrap", stack_file:$stack, git_revision:"main"}')" +trigger_run bootstrap "$bootstrap_payload" +bootstrap_run="$TRIGGERED_STACKRUN" +wait_for_stackrun "$bootstrap_run" bootstrap + +echo ">>> Trigger $INTERCEPT_BACKEND PR path through authenticated orchestrator API" +pr_payload="$(jq -nc \ + --arg stack "stacks/$STACK_FILE" \ + --arg app "$CHANGED_APP" \ + --arg backend "$INTERCEPT_BACKEND" \ + --argjson pr "$PR_NUMBER" \ + '{mode:"pr", stack_file:$stack, changed_app:$app, pr_number:$pr, + git_revision:"main", intercept_backend:$backend}')" +trigger_run pr "$pr_payload" +pr_run="$TRIGGERED_STACKRUN" +wait_for_stackrun "$pr_run" pr +collect_run_evidence "$pr_run" pr +verify_pr_evidence "$pr_run" + +echo "OK: trigger -> StackRun -> operator -> PR PipelineRun -> $INTERCEPT_BACKEND tests -> cleanup" diff --git a/tasks/clone-app-repos.yaml b/tasks/clone-app-repos.yaml index ffa206e..424ece2 100644 --- a/tasks/clone-app-repos.yaml +++ b/tasks/clone-app-repos.yaml @@ -7,11 +7,10 @@ metadata: app.kubernetes.io/version: "1.0.0" spec: description: > - Clones each app repo from the stack into workspace/ via SSH. + Clones each app repo from the stack into workspace/. Run after fetch-source and resolve-stack. Stack .apps[].repo (e.g. - jmjava/tekton-dag-vue-fe) becomes git@github.com:.git. Workspace - ssh-key (required) must hold the SSH private key (id_ed25519 or id_rsa) - for GitHub auth. + jmjava/tekton-dag-vue-fe) uses SSH when the workspace contains a private + key and HTTPS otherwise. Private repositories require the SSH key. params: - name: stack-json type: string @@ -31,7 +30,7 @@ spec: - name: source description: "Workspace with platform repo at root; app repos will be cloned into source/" - name: ssh-key - description: "Volume with SSH private key (id_ed25519 or id_rsa) for git clone; required for SSH pull" + description: "Volume that may contain id_ed25519 or id_rsa for private-repository SSH clones" steps: - name: clone-apps image: alpine/git:latest @@ -53,21 +52,26 @@ spec: rm -rf "$WORKSPACE/$APP" done - # SSH clone: use git@github.com:.git; ssh-key workspace is required + # Prefer authenticated SSH when a key is present. Public repositories + # can be cloned over HTTPS from an empty Secret workspace. mkdir -p /root/.ssh chmod 700 /root/.ssh KEY_DIR="$(workspaces.ssh-key.path)" + CLONE_TRANSPORT=https if [ -f "$KEY_DIR/id_ed25519" ]; then cp "$KEY_DIR/id_ed25519" /root/.ssh/id_ed25519 chmod 600 /root/.ssh/id_ed25519 + CLONE_TRANSPORT=ssh elif [ -f "$KEY_DIR/id_rsa" ]; then cp "$KEY_DIR/id_rsa" /root/.ssh/id_rsa chmod 600 /root/.ssh/id_rsa + CLONE_TRANSPORT=ssh else - echo " ERROR: ssh-key workspace must contain id_ed25519 or id_rsa" >&2 - exit 1 + echo " No SSH key found; cloning public repositories over HTTPS" + fi + if [ "$CLONE_TRANSPORT" = "ssh" ]; then + ssh-keyscan -H github.com >> /root/.ssh/known_hosts 2>/dev/null || true fi - ssh-keyscan -H github.com >> /root/.ssh/known_hosts 2>/dev/null || true # Clone all app repos in parallel (M7.1) clone_app() { @@ -87,7 +91,11 @@ spec: fi REVISION=$(echo "$APP_REVISIONS" | jq -r --arg a "$APP" '.[$a] // empty') [ -z "$REVISION" ] && REVISION="$DEFAULT_REVISION" - URL="git@github.com:${REPO}.git" + if [ "$CLONE_TRANSPORT" = "ssh" ]; then + URL="git@github.com:${REPO}.git" + else + URL="https://github.com/${REPO}.git" + fi clone_app "$APP" "$REPO" "$REVISION" "$URL" & PIDS="$PIDS $!" done diff --git a/tasks/deploy-intercept-mirrord.yaml b/tasks/deploy-intercept-mirrord.yaml index 79314d2..f701e4d 100644 --- a/tasks/deploy-intercept-mirrord.yaml +++ b/tasks/deploy-intercept-mirrord.yaml @@ -35,7 +35,7 @@ spec: default: "staging" - name: mirrord-image type: string - default: "localhost:5001/tekton-dag-build-mirrord:latest" + default: "localhost:5000/tekton-dag-build-mirrord:latest" description: "Image for mirrord proxy pods (mirrord CLI + socat)" workspaces: - name: shared-workspace diff --git a/tasks/tekton-dag-hook-noop.yaml b/tasks/tekton-dag-hook-noop.yaml new file mode 100644 index 0000000..797a762 --- /dev/null +++ b/tasks/tekton-dag-hook-noop.yaml @@ -0,0 +1,29 @@ +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: tekton-dag-hook-noop + labels: + app.kubernetes.io/part-of: tekton-job-standardization + app.kubernetes.io/version: "1.0.0" +spec: + description: > + Resolvable sentinel for optional remote hook references. Pipelines skip this + Task with a when expression; its contract keeps validation deterministic. + params: + - name: stack-json + default: "" + - name: build-apps + default: "" + - name: built-images + default: "{}" + - name: image-registry + default: "" + workspaces: + - name: source + optional: true + steps: + - name: noop + image: alpine:3.20 + script: | + #!/bin/sh + echo "Optional hook not configured" diff --git a/tests/postman/management-gui-tests.json b/tests/postman/management-gui-tests.json index 2acdafd..411016e 100644 --- a/tests/postman/management-gui-tests.json +++ b/tests/postman/management-gui-tests.json @@ -25,10 +25,13 @@ "item": [ { "name": "POST trigger rejects missing bearer token", - "auth": { "type": "noauth" }, "request": { + "auth": { "type": "noauth" }, "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "" } + ], "body": { "mode": "raw", "raw": "{}" }, "url": "{{baseUrl}}/api/teams/{{team}}/trigger" }, @@ -43,15 +46,13 @@ }, { "name": "POST trigger rejects invalid bearer token", - "auth": { - "type": "bearer", - "bearer": [ - { "key": "token", "value": "invalid-token", "type": "string" } - ] - }, "request": { + "auth": { "type": "noauth" }, "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer invalid-token" } + ], "body": { "mode": "raw", "raw": "{}" }, "url": "{{baseUrl}}/api/teams/{{team}}/trigger" }, diff --git a/tests/postman/orchestrator-tests.json b/tests/postman/orchestrator-tests.json index 8523efe..4040d6c 100644 --- a/tests/postman/orchestrator-tests.json +++ b/tests/postman/orchestrator-tests.json @@ -42,15 +42,19 @@ "item": [ { "name": "POST /api/run rejects missing bearer token", - "auth": { - "type": "noauth" - }, "request": { + "auth": { + "type": "noauth" + }, "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "" } ], "body": { @@ -72,22 +76,19 @@ }, { "name": "POST /api/run rejects invalid bearer token", - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "invalid-token", - "type": "string" - } - ] - }, "request": { + "auth": { + "type": "noauth" + }, "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer invalid-token" } ], "body": {