From 76f24220e62363315458dc0d5045d617eb1337e4 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 26 Jan 2026 16:04:33 -0800 Subject: [PATCH 1/9] feat: add local integration testing infrastructure (envtest + kind) - Enable envtest suite: remove Skip(), fix CRD paths, add required schemes - Add Ginkgo-based envtest tests for Site, Connect, Workbench, PackageManager CRDs - Add kind integration tests with make targets (test-kind, test-kind-full) - Add GitHub Actions workflow for CI (envtest on PRs, kind on main/nightly) - Add comprehensive testing documentation Closes #51 --- .github/workflows/integration-tests.yml | 164 +++++++++++ Makefile | 35 +++ docs/testing.md | 244 +++++++++++++++++ hack/test-kind.sh | 259 ++++++++++++++++++ internal/controller/core/site_envtest_test.go | 225 +++++++++++++++ internal/controller/core/suite_test.go | 81 +++++- 6 files changed, 995 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 docs/testing.md create mode 100755 hack/test-kind.sh create mode 100644 internal/controller/core/site_envtest_test.go diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..e10324cf --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,164 @@ +# Team Operator Integration Tests +# +# This workflow runs integration tests using both: +# - envtest (API-level tests, fast) +# - kind cluster (full stack tests, slower) +# +# Envtest runs on every PR, kind tests run on PRs to main or nightly. + +name: Integration Tests + +on: + push: + branches: + - main + paths-ignore: + - '**/*.md' + - 'docs/**' + - '.claude/**' + - 'LICENSE' + pull_request: + paths-ignore: + - '**/*.md' + - 'docs/**' + - '.claude/**' + - 'LICENSE' + schedule: + # Run nightly at 2:00 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + run_kind_tests: + description: 'Run kind integration tests' + required: false + default: 'true' + type: boolean + +permissions: + contents: read + +env: + GO_VERSION: '1.22' + KIND_VERSION: 'v0.23.0' + KIND_CLUSTER_NAME: 'team-operator-test' + +jobs: + envtest: + name: Envtest (API-level tests) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Cache envtest binaries + uses: actions/cache@v4 + with: + path: bin/ + key: ${{ runner.os }}-envtest-${{ hashFiles('Makefile') }} + restore-keys: | + ${{ runner.os }}-envtest- + + - name: Install envtest + run: make envtest + + - name: Run envtest suite + run: make go-test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage.out + flags: envtest + fail_ci_if_error: false + + kind: + name: Kind (full stack tests) + runs-on: ubuntu-latest + # Run on main branch, nightly, or manual trigger + if: | + github.ref == 'refs/heads/main' || + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_kind_tests == 'true') + needs: envtest # Only run if envtest passes + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Install kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Create kind cluster + run: make kind-create KIND_CLUSTER_NAME=${{ env.KIND_CLUSTER_NAME }} + + - name: Build operator image + run: | + make build + docker build -t controller:latest . + + - name: Load image into kind + run: | + kind load docker-image controller:latest --name ${{ env.KIND_CLUSTER_NAME }} + + - name: Run integration tests + run: | + make test-kind KIND_CLUSTER_NAME=${{ env.KIND_CLUSTER_NAME }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Collect cluster logs on failure + if: failure() + run: | + echo "=== Cluster Info ===" + kubectl cluster-info --context kind-${{ env.KIND_CLUSTER_NAME }} + echo "" + echo "=== All Pods ===" + kubectl get pods -A + echo "" + echo "=== Events ===" + kubectl get events -A --sort-by='.lastTimestamp' + echo "" + echo "=== Operator Logs ===" + kubectl logs -n posit-team-system -l app.kubernetes.io/name=team-operator --tail=100 || true + + - name: Delete kind cluster + if: always() + run: make kind-delete KIND_CLUSTER_NAME=${{ env.KIND_CLUSTER_NAME }} + + # Summary job for branch protection + integration-tests-complete: + name: Integration Tests Complete + runs-on: ubuntu-latest + needs: [envtest] + if: always() + steps: + - name: Check results + run: | + if [[ "${{ needs.envtest.result }}" != "success" ]]; then + echo "Envtest failed" + exit 1 + fi + echo "All required tests passed!" diff --git a/Makefile b/Makefile index 28520e9e..afd88884 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,41 @@ cov: ## Show the coverage report at the function level. $(SED) -i '/team-operator\/client-go/d' coverage.out go tool cover -func coverage.out +##@ Integration Testing + +KIND_CLUSTER_NAME ?= team-operator-test +KIND_VERSION ?= 1.29.x + +.PHONY: kind-create +kind-create: ## Create a kind cluster for integration testing. + @if kind get clusters | grep -q "^$(KIND_CLUSTER_NAME)$$"; then \ + echo "Kind cluster '$(KIND_CLUSTER_NAME)' already exists"; \ + else \ + echo "Creating kind cluster '$(KIND_CLUSTER_NAME)'..."; \ + kind create cluster --name $(KIND_CLUSTER_NAME) --wait 60s; \ + fi + +.PHONY: kind-delete +kind-delete: ## Delete the kind cluster. + kind delete cluster --name $(KIND_CLUSTER_NAME) || true + +.PHONY: kind-load-image +kind-load-image: docker-build ## Load the operator image into kind cluster. + kind load docker-image $(IMG) --name $(KIND_CLUSTER_NAME) + +.PHONY: test-kind +test-kind: kind-create ## Run integration tests on a kind cluster. + @echo "Running integration tests on kind cluster '$(KIND_CLUSTER_NAME)'..." + ./hack/test-kind.sh $(KIND_CLUSTER_NAME) + +.PHONY: test-kind-full +test-kind-full: kind-delete kind-create test-kind ## Run full integration tests (clean cluster). + @echo "Full integration test completed." + +.PHONY: test-integration +test-integration: go-test test-kind ## Run all tests (unit + integration). + @echo "All tests completed." + ##@ Build .PHONY: build diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..3fa35ab0 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,244 @@ +# Testing Guide + +This document describes the testing infrastructure for the Team Operator. + +## Testing Tiers + +The Team Operator uses a two-tier local integration testing strategy: + +### Tier 1: Envtest (Fast API Tests) + +**What it is:** Envtest uses a lightweight, embedded Kubernetes API server (etcd + kube-apiserver) to test controller logic without a full cluster. + +**When to use:** For testing controller reconciliation logic, CRD validation, and API interactions. + +**Execution time:** Seconds + +**What it tests:** +- CRD schema validation +- Controller reconciliation logic +- Resource creation and updates +- Status updates + +### Tier 2: Kind Cluster (Full Stack Tests) + +**What it is:** Kind (Kubernetes IN Docker) creates a real Kubernetes cluster using Docker containers. + +**When to use:** For end-to-end testing, Helm chart deployment, and integration with other Kubernetes components. + +**Execution time:** Minutes + +**What it tests:** +- Helm chart deployment +- Full operator lifecycle +- Inter-pod communication +- Actual resource creation in Kubernetes + +## Prerequisites + +### For Envtest + +Envtest binaries are automatically downloaded by the Makefile: + +```bash +make envtest +``` + +### For Kind Tests + +Install these tools: + +```bash +# Install kind +# macOS +brew install kind + +# Linux +curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64 +chmod +x ./kind +sudo mv ./kind /usr/local/bin/kind + +# Install kubectl (if not already installed) +# macOS +brew install kubectl + +# Install Helm +# macOS +brew install helm + +# Linux +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + +# Verify Docker is running +docker info +``` + +## Running Tests + +### Unit Tests (includes Envtest) + +Run all Go tests including envtest-based controller tests: + +```bash +make go-test +``` + +Or run the full test suite with code generation: + +```bash +make test +``` + +### Envtest Tests Only + +To run only the Ginkgo-based envtest suite: + +```bash +KUBEBUILDER_ASSETS="$(pwd)/$(bin/setup-envtest use 1.29.x --bin-dir bin -p path)" \ + go test -v ./internal/controller/core/... -run "TestControllers" +``` + +### Kind Integration Tests + +Create a kind cluster and run integration tests: + +```bash +# Create kind cluster +make kind-create + +# Run integration tests +make test-kind + +# Clean up +make kind-delete +``` + +For a full clean run: + +```bash +make test-kind-full +``` + +### All Tests + +Run both unit tests and integration tests: + +```bash +make test-integration +``` + +## Test Structure + +### Envtest Suite (`internal/controller/core/suite_test.go`) + +The envtest suite sets up a test environment with: +- Embedded etcd and kube-apiserver +- All operator CRDs loaded +- A `posit-team` namespace for test resources + +Example test file: `internal/controller/core/site_envtest_test.go` + +```go +var _ = Describe("Site Controller (envtest)", func() { + Context("When creating a Site CR", func() { + It("Should create child resources", func() { + // Test code using k8sClient from suite_test.go + }) + }) +}) +``` + +### Kind Tests (`hack/test-kind.sh`) + +The kind test script: +1. Verifies prerequisites (kind, kubectl, helm) +2. Installs CRDs +3. Deploys the operator via Helm +4. Creates test resources +5. Validates reconciliation +6. Cleans up + +## CI Integration + +Integration tests run automatically via GitHub Actions: + +| Event | Envtest | Kind | +|-------|---------|------| +| Pull Request | Yes | No | +| Push to main | Yes | Yes | +| Nightly schedule | Yes | Yes | +| Manual trigger | Yes | Configurable | + +See `.github/workflows/integration-tests.yml` for details. + +## Troubleshooting + +### Envtest fails with "no such file or directory" + +The envtest binaries need to be downloaded: + +```bash +make envtest +``` + +Or ensure KUBEBUILDER_ASSETS is set to an absolute path: + +```bash +export KUBEBUILDER_ASSETS="$(pwd)/bin/k8s/1.29.5-$(go env GOOS)-$(go env GOARCH)" +``` + +### Kind cluster won't start + +Check Docker is running: + +```bash +docker info +``` + +Check for existing clusters: + +```bash +kind get clusters +``` + +Delete and recreate: + +```bash +make kind-delete kind-create +``` + +### Tests hang or timeout + +For envtest, ensure no other test environment is running. + +For kind, check cluster health: + +```bash +kubectl cluster-info --context kind-team-operator-test +kubectl get pods -A +``` + +## Writing New Tests + +### Adding Envtest Tests + +1. Use the existing `suite_test.go` setup +2. Create a new `*_test.go` file with Ginkgo `Describe` blocks +3. Use `k8sClient` for API operations +4. Use `ctx` for context +5. Clean up resources after each test + +### Adding Kind Tests + +1. Add test functions to `hack/test-kind.sh` +2. Follow the naming convention: `test_` +3. Use the helper functions (`log_info`, `wait_for`, etc.) +4. Ensure proper cleanup in the `cleanup` function + +## Best Practices + +1. **Use envtest for unit-level controller tests** - It's fast and doesn't require Docker +2. **Use kind for integration tests** - When you need a real cluster +3. **Always clean up test resources** - Prevents test pollution +4. **Use Eventually() for async operations** - Controllers are eventually consistent +5. **Keep test data minimal** - Only specify fields needed for the test diff --git a/hack/test-kind.sh b/hack/test-kind.sh new file mode 100755 index 00000000..b507ab70 --- /dev/null +++ b/hack/test-kind.sh @@ -0,0 +1,259 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# Copyright (c) 2023-2026 Posit Software, PBC +# +# test-kind.sh - Integration tests on a kind cluster +# +# This script runs integration tests against a kind cluster to verify +# the team-operator can: +# 1. Deploy successfully via Helm +# 2. Create and reconcile Site CRs +# 3. Clean up properly +# +# Usage: ./hack/test-kind.sh [CLUSTER_NAME] + +set -euo pipefail + +CLUSTER_NAME="${1:-team-operator-test}" +NAMESPACE="posit-team-system" +RELEASE_NAME="team-operator" +CHART_DIR="dist/chart" +TIMEOUT="120s" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Ensure kubectl is using the right context +ensure_context() { + local expected_context="kind-${CLUSTER_NAME}" + local current_context + current_context=$(kubectl config current-context 2>/dev/null || echo "") + + if [[ "$current_context" != "$expected_context" ]]; then + log_info "Switching kubectl context to ${expected_context}" + kubectl config use-context "$expected_context" + fi +} + +# Wait for a condition with timeout +wait_for() { + local description="$1" + local timeout="$2" + shift 2 + local cmd=("$@") + + log_info "Waiting for: ${description} (timeout: ${timeout})" + + local end_time=$((SECONDS + ${timeout%s})) + while [[ $SECONDS -lt $end_time ]]; do + if "${cmd[@]}" &>/dev/null; then + log_info "Success: ${description}" + return 0 + fi + sleep 2 + done + + log_error "Timeout waiting for: ${description}" + return 1 +} + +# Check prerequisites +check_prerequisites() { + log_info "Checking prerequisites..." + + local missing=() + + command -v kind &>/dev/null || missing+=("kind") + command -v kubectl &>/dev/null || missing+=("kubectl") + command -v helm &>/dev/null || missing+=("helm") + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required tools: ${missing[*]}" + log_error "Please install them before running this script." + exit 1 + fi + + # Check if kind cluster exists + if ! kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + log_error "Kind cluster '${CLUSTER_NAME}' does not exist" + log_error "Run 'make kind-create' first" + exit 1 + fi + + log_info "Prerequisites check passed" +} + +# Install CRDs +install_crds() { + log_info "Installing CRDs..." + kubectl apply -f config/crd/bases/ + log_info "CRDs installed" +} + +# Deploy the operator via Helm +deploy_operator() { + log_info "Deploying team-operator via Helm..." + + # Create namespace if it doesn't exist + kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + + # Install or upgrade the operator + helm upgrade --install "${RELEASE_NAME}" "${CHART_DIR}" \ + --namespace "${NAMESPACE}" \ + --set image.repository=controller \ + --set image.tag=latest \ + --set image.pullPolicy=Never \ + --wait \ + --timeout "${TIMEOUT}" || { + log_warn "Helm install failed, checking pod status..." + kubectl get pods -n "${NAMESPACE}" -o wide + kubectl describe pods -n "${NAMESPACE}" + return 1 + } + + log_info "Operator deployed successfully" +} + +# Wait for operator to be ready +wait_for_operator() { + log_info "Waiting for operator to be ready..." + + wait_for "operator deployment ready" "${TIMEOUT}" \ + kubectl rollout status deployment/"${RELEASE_NAME}" -n "${NAMESPACE}" + + # Additional check for pod readiness + local pod_name + pod_name=$(kubectl get pods -n "${NAMESPACE}" -l "app.kubernetes.io/name=team-operator" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") + + if [[ -n "$pod_name" ]]; then + log_info "Operator pod: ${pod_name}" + kubectl logs -n "${NAMESPACE}" "${pod_name}" --tail=20 || true + fi +} + +# Test: Verify CRDs are installed +test_crds_installed() { + log_info "Testing: CRDs are installed..." + + local crds=("sites.core.posit.team" "connects.core.posit.team" "workbenches.core.posit.team" "packagemanagers.core.posit.team") + local failed=() + + for crd in "${crds[@]}"; do + if kubectl get crd "$crd" &>/dev/null; then + log_info " CRD found: $crd" + else + failed+=("$crd") + fi + done + + if [[ ${#failed[@]} -gt 0 ]]; then + log_error "Missing CRDs: ${failed[*]}" + return 1 + fi + + log_info "Test passed: All CRDs installed" +} + +# Test: Create a minimal Site CR +test_create_site() { + log_info "Testing: Create Site CR..." + + local test_namespace="posit-team" + local site_name="test-site-kind" + + # Create test namespace + kubectl create namespace "${test_namespace}" --dry-run=client -o yaml | kubectl apply -f - + + # Create a minimal Site CR + cat < Date: Thu, 19 Feb 2026 12:08:53 -0800 Subject: [PATCH 2/9] test: address envtest review findings - Rename misleading test "Should create child resources" to accurately describe what it tests (Site CR creation and retrieval) - Add DeferCleanup to first Site test to match cleanup pattern of others - Remove ineffective validation test that accepted both success/failure - Remove unused setupSiteControllerForEnvtest and waitFor helpers - Remove unused GO_VERSION env var from integration-tests.yml --- .github/workflows/integration-tests.yml | 1 - internal/controller/core/site_envtest_test.go | 32 +++-------------- internal/controller/core/suite_test.go | 35 ------------------- 3 files changed, 4 insertions(+), 64 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e10324cf..b74cc343 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -38,7 +38,6 @@ permissions: contents: read env: - GO_VERSION: '1.22' KIND_VERSION: 'v0.23.0' KIND_CLUSTER_NAME: 'team-operator-test' diff --git a/internal/controller/core/site_envtest_test.go b/internal/controller/core/site_envtest_test.go index 9c7b25dd..15cb02eb 100644 --- a/internal/controller/core/site_envtest_test.go +++ b/internal/controller/core/site_envtest_test.go @@ -25,7 +25,7 @@ var _ = Describe("Site Controller (envtest)", func() { ) Context("When creating a Site CR", func() { - It("Should create child resources (Connect, Workbench, etc.)", func() { + It("Should be able to create and retrieve a Site CR", func() { By("Creating a test namespace") testNamespace := "envtest-site-ns" ns := &corev1.Namespace{ @@ -65,6 +65,9 @@ var _ = Describe("Site Controller (envtest)", func() { }, } Expect(k8sClient.Create(ctx, site)).To(Succeed()) + DeferCleanup(func() { + Expect(k8sClient.Delete(ctx, site)).To(Succeed()) + }) By("Verifying the Site CR was created") siteKey := types.NamespacedName{Name: siteName, Namespace: testNamespace} @@ -79,33 +82,6 @@ var _ = Describe("Site Controller (envtest)", func() { }) }) - Context("When validating Site CRD schema", func() { - It("Should reject invalid Site specs", func() { - By("Creating a Site with missing required fields") - testNamespace := "posit-team" - invalidSite := &corev1beta1.Site{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "core.posit.team/v1beta1", - Kind: "Site", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "invalid-site", - Namespace: testNamespace, - }, - // Empty spec - the CRD should still accept this as fields are optional - Spec: corev1beta1.SiteSpec{}, - } - // This should succeed because the CRD doesn't require most fields - err := k8sClient.Create(ctx, invalidSite) - // The create might succeed or fail depending on CRD validation - // We just want to verify the API server is working - if err == nil { - // Clean up - Expect(k8sClient.Delete(ctx, invalidSite)).To(Succeed()) - } - }) - }) - Context("When testing Connect CRD", func() { It("Should be able to create a Connect resource directly", func() { testNamespace := "posit-team" diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 3c56a79b..f87f822f 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -7,7 +7,6 @@ import ( "context" "path/filepath" "testing" - "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,7 +15,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -99,36 +97,3 @@ var _ = AfterSuite(func() { err := testEnv.Stop() Expect(err).NotTo(HaveOccurred()) }) - -// Helper to start a manager with the Site controller for envtest -func setupSiteControllerForEnvtest(ctx context.Context) (ctrl.Manager, error) { - mgr, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - }) - if err != nil { - return nil, err - } - - err = (&SiteReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Log: ctrl.Log.WithName("controllers").WithName("Site"), - }).SetupWithManager(mgr) - if err != nil { - return nil, err - } - - return mgr, nil -} - -// Helper to wait for a condition with timeout -func waitFor(timeout time.Duration, condition func() bool) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if condition() { - return true - } - time.Sleep(100 * time.Millisecond) - } - return false -} From 952aa6c25565d61179e6a64f8f9ac61e777fc07d Mon Sep 17 00:00:00 2001 From: ian-flores Date: Thu, 19 Feb 2026 13:02:35 -0800 Subject: [PATCH 3/9] fix: filter test packages to resolve covdata error in Go 1.25 go test ./... with -covermode=atomic fails for packages with no test files in Go 1.25 due to covdata removal. Filter to only packages with test files using go list. Also improves kind integration tests: - Add docker-build and kind-load-image as test-kind prerequisites so the operator image is always present before running tests - Add test_reconciliation to verify the controller creates Connect and Workbench child CRs after a Site CR is applied - Add test_operator_logs to detect panics and confirm reconciliation activity in operator logs --- Makefile | 4 +- hack/test-kind.sh | 125 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index d0844fca..f22cf6b8 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ test: manifests generate-all fmt vet go-test cov ## Run generation and test comm .PHONY: go-test go-test: envtest ## Run only the go tests. KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use "$(ENVTEST_K8S_VERSION)" --bin-dir "$(LOCALBIN)" -p path)" \ - go test -v ./... -race -covermode=atomic -coverprofile coverage.out + sh -c 'go test -buildvcs=false -v $$(go list -buildvcs=false -f '\''{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}'\'' ./...) -race -covermode=atomic -coverprofile coverage.out' .PHONY: cov cov: ## Show the coverage report at the function level. @@ -149,7 +149,7 @@ kind-load-image: docker-build ## Load the operator image into kind cluster. kind load docker-image $(IMG) --name $(KIND_CLUSTER_NAME) .PHONY: test-kind -test-kind: kind-create ## Run integration tests on a kind cluster. +test-kind: kind-create docker-build kind-load-image ## Run integration tests on a kind cluster. @echo "Running integration tests on kind cluster '$(KIND_CLUSTER_NAME)'..." ./hack/test-kind.sh $(KIND_CLUSTER_NAME) diff --git a/hack/test-kind.sh b/hack/test-kind.sh index b507ab70..786e56d4 100755 --- a/hack/test-kind.sh +++ b/hack/test-kind.sh @@ -213,6 +213,129 @@ EOF log_info "Site CR cleaned up" } +# Test: Verify operator reconciled the Site +test_reconciliation() { + log_info "Testing: Site reconciliation..." + + local test_namespace="posit-team" + local site_name="test-site-reconcile" + + # Create test namespace + kubectl create namespace "${test_namespace}" --dry-run=client -o yaml | kubectl apply -f - + + # Create a Site CR + cat </dev/null; then + connect_exists=true + fi + + if kubectl get workbench "${site_name}" -n "${test_namespace}" &>/dev/null; then + workbench_exists=true + fi + + if [[ "$connect_exists" == true ]] && [[ "$workbench_exists" == true ]]; then + log_info "Child CRs created successfully" + break + fi + + sleep 2 + done + + # Assert child CRs exist — fail if reconciliation did not produce them + local failed=false + + if kubectl get connect "${site_name}" -n "${test_namespace}" &>/dev/null; then + log_info " Connect CR found: ${site_name}" + kubectl get connect "${site_name}" -n "${test_namespace}" -o jsonpath='{.status}' || true + else + log_error " Connect CR not found: ${site_name} (reconciliation may not have run)" + failed=true + fi + + if kubectl get workbench "${site_name}" -n "${test_namespace}" &>/dev/null; then + log_info " Workbench CR found: ${site_name}" + kubectl get workbench "${site_name}" -n "${test_namespace}" -o jsonpath='{.status}' || true + else + log_error " Workbench CR not found: ${site_name} (reconciliation may not have run)" + failed=true + fi + + if [[ "$failed" == true ]]; then + log_error "Test failed: Site reconciliation did not produce expected child CRs" + return 1 + fi + + log_info "Test passed: Site reconciliation verified" + + # Cleanup + kubectl delete site "${site_name}" -n "${test_namespace}" --ignore-not-found + log_info "Site CR cleaned up" +} + +# Test: Check operator logs for errors +test_operator_logs() { + log_info "Testing: Operator logs..." + + local pod_name + pod_name=$(kubectl get pods -n "${NAMESPACE}" -l "app.kubernetes.io/name=team-operator" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") + + if [[ -z "$pod_name" ]]; then + log_warn "Operator pod not found, skipping log check" + return 0 + fi + + log_info "Operator pod: ${pod_name}" + + # Get logs and check for common error patterns + local logs + logs=$(kubectl logs -n "${NAMESPACE}" "${pod_name}" --tail=100 2>&1 || echo "") + + # Check for panic + if echo "$logs" | grep -i "panic:" &>/dev/null; then + log_error "Found panic in operator logs" + echo "$logs" | grep -A 10 -i "panic:" + return 1 + fi + + # Check for reconciliation activity + if echo "$logs" | grep -i "reconcil" &>/dev/null; then + log_info "Operator is reconciling resources" + fi + + # Show recent reconciliation messages + echo "$logs" | grep -E "Site found|Site not found|reconcil" | tail -10 || true + + log_info "Test passed: Operator logs look healthy" +} + # Cleanup function cleanup() { log_info "Cleaning up..." @@ -244,11 +367,13 @@ main() { if [[ -d "${CHART_DIR}" ]]; then deploy_operator wait_for_operator + test_operator_logs else log_warn "Helm chart not found at ${CHART_DIR}, skipping operator deployment tests" fi test_create_site + test_reconciliation log_info "" log_info "==========================================" From 36f4f0ab08e0cb6a8fff37c524523f29b3ddf7b8 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 23 Feb 2026 08:28:43 -0800 Subject: [PATCH 4/9] fix: correct kind test infrastructure to run end-to-end --- Dockerfile | 2 +- Makefile | 6 +++++- hack/test-kind.sh | 33 ++++++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index c19f2fe6..d7764d22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG GO_VERSION=1.24 +ARG GO_VERSION=1.25 # Build the team-operator binary FROM golang:${GO_VERSION} AS builder ARG TARGETOS diff --git a/Makefile b/Makefile index f22cf6b8..2325f4cc 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ kind-load-image: docker-build ## Load the operator image into kind cluster. kind load docker-image $(IMG) --name $(KIND_CLUSTER_NAME) .PHONY: test-kind -test-kind: kind-create docker-build kind-load-image ## Run integration tests on a kind cluster. +test-kind: kind-create docker-build ## Build operator image and run integration tests on a kind cluster. @echo "Running integration tests on kind cluster '$(KIND_CLUSTER_NAME)'..." ./hack/test-kind.sh $(KIND_CLUSTER_NAME) @@ -167,6 +167,10 @@ test-integration: go-test test-kind ## Run all tests (unit + integration). build: manifests generate-all fmt vet ## Build manager binary. go build -o bin/team-operator ./cmd/team-operator/main.go +.PHONY: docker-build +docker-build: build ## Build the operator Docker image. + docker build -t $(IMG) . + .PHONY: distclean distclean: git clean -xd bin/ || true diff --git a/hack/test-kind.sh b/hack/test-kind.sh index 786e56d4..4f0264de 100755 --- a/hack/test-kind.sh +++ b/hack/test-kind.sh @@ -18,6 +18,9 @@ CLUSTER_NAME="${1:-team-operator-test}" NAMESPACE="posit-team-system" RELEASE_NAME="team-operator" CHART_DIR="dist/chart" +# Use a non-latest tag so Kubernetes defaults to imagePullPolicy=IfNotPresent, +# which uses the locally loaded image instead of pulling from a registry. +LOCAL_IMAGE="controller:kind-test" TIMEOUT="120s" # Colors for output @@ -109,15 +112,24 @@ install_crds() { deploy_operator() { log_info "Deploying team-operator via Helm..." - # Create namespace if it doesn't exist + # Create both namespaces: operator system namespace and the watched namespace kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + kubectl create namespace posit-team --dry-run=client -o yaml | kubectl apply -f - - # Install or upgrade the operator + # Load the locally built image into kind with a non-latest tag. + # Using a non-latest tag causes Kubernetes to default to imagePullPolicy=IfNotPresent, + # so it uses the locally loaded image instead of attempting to pull from a registry. + docker tag controller:latest "${LOCAL_IMAGE}" + kind load docker-image "${LOCAL_IMAGE}" --name "${CLUSTER_NAME}" + + local image_repo="${LOCAL_IMAGE%%:*}" + local image_tag="${LOCAL_IMAGE##*:}" + + # Install or upgrade the operator using the chart's value path helm upgrade --install "${RELEASE_NAME}" "${CHART_DIR}" \ --namespace "${NAMESPACE}" \ - --set image.repository=controller \ - --set image.tag=latest \ - --set image.pullPolicy=Never \ + --set "controllerManager.container.image.repository=${image_repo}" \ + --set "controllerManager.container.image.tag=${image_tag}" \ --wait \ --timeout "${TIMEOUT}" || { log_warn "Helm install failed, checking pod status..." @@ -134,7 +146,7 @@ wait_for_operator() { log_info "Waiting for operator to be ready..." wait_for "operator deployment ready" "${TIMEOUT}" \ - kubectl rollout status deployment/"${RELEASE_NAME}" -n "${NAMESPACE}" + kubectl rollout status deployment/"${RELEASE_NAME}-controller-manager" -n "${NAMESPACE}" # Additional check for pod readiness local pod_name @@ -187,6 +199,7 @@ metadata: name: ${site_name} namespace: ${test_namespace} spec: + domain: "test.example.com" flightdeck: image: "nginx:latest" workloadSecret: @@ -360,15 +373,17 @@ main() { # Run tests with cleanup on exit trap cleanup EXIT - install_crds - test_crds_installed - # Only run operator deployment tests if chart exists if [[ -d "${CHART_DIR}" ]]; then + # Let Helm manage CRD installation — pre-installing via kubectl causes ownership conflicts deploy_operator wait_for_operator + test_crds_installed test_operator_logs else + # No Helm chart: install CRDs directly and skip operator deployment + install_crds + test_crds_installed log_warn "Helm chart not found at ${CHART_DIR}, skipping operator deployment tests" fi From 1eb6555419d2ecd7dc6e86dd5a8cb58ddc1ae59a Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 23 Feb 2026 08:28:47 -0800 Subject: [PATCH 5/9] fix: use errors.As for SecretProviderClass CRD absence check --- internal/controller/core/site_controller_home_cleanup.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/controller/core/site_controller_home_cleanup.go b/internal/controller/core/site_controller_home_cleanup.go index 8049f96e..555b3dcd 100644 --- a/internal/controller/core/site_controller_home_cleanup.go +++ b/internal/controller/core/site_controller_home_cleanup.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "github.com/posit-dev/team-operator/internal" v1 "k8s.io/api/apps/v1" @@ -61,8 +62,10 @@ func (r *SiteReconciler) cleanupLegacyHomeApp( } existingSpc := &secretsstorev1.SecretProviderClass{} if err := internal.BasicDelete(ctx, r, l, spcKey, existingSpc); err != nil { - // Check if the error is because the CRD doesn't exist - if _, isNoKindMatch := err.(*meta.NoKindMatchError); isNoKindMatch { + // Check if the error is because the CRD doesn't exist. + // Use errors.As to handle wrapped errors (direct type assertion misses wrapping). + var noKindMatch *meta.NoKindMatchError + if errors.As(err, &noKindMatch) { l.V(1).Info("SecretProviderClass CRD not available, skipping cleanup") } else { l.Error(err, "error deleting legacy home SecretProviderClass") From 5d3937c68fdfe122232890d981eedf60b28ae198 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 23 Feb 2026 08:48:31 -0800 Subject: [PATCH 6/9] test: regenerate Helm chart before kind integration tests --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2325f4cc..8658dd5e 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ kind-load-image: docker-build ## Load the operator image into kind cluster. kind load docker-image $(IMG) --name $(KIND_CLUSTER_NAME) .PHONY: test-kind -test-kind: kind-create docker-build ## Build operator image and run integration tests on a kind cluster. +test-kind: kind-create docker-build helm-generate ## Build operator image and run integration tests on a kind cluster. @echo "Running integration tests on kind cluster '$(KIND_CLUSTER_NAME)'..." ./hack/test-kind.sh $(KIND_CLUSTER_NAME) From 7e0d94b2baf716152bc3bcbc2355fe0117529fe1 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 23 Feb 2026 09:44:49 -0800 Subject: [PATCH 7/9] ci: run kind tests on PRs touching relevant paths --- .github/workflows/integration-tests.yml | 31 ++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index b74cc343..c9b7f172 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -4,7 +4,9 @@ # - envtest (API-level tests, fast) # - kind cluster (full stack tests, slower) # -# Envtest runs on every PR, kind tests run on PRs to main or nightly. +# Envtest runs on every PR. Kind tests run on every PR that touches +# relevant paths (api/, internal/, Dockerfile, etc.), on push to main, +# nightly, and on manual dispatch. name: Integration Tests @@ -42,6 +44,28 @@ env: KIND_CLUSTER_NAME: 'team-operator-test' jobs: + check-changes: + name: Detect relevant changes + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + relevant: + - 'api/**' + - 'internal/**' + - 'cmd/**' + - 'hack/test-kind.sh' + - 'dist/chart/**' + - 'Dockerfile' + - 'Makefile' + - 'go.mod' + - 'go.sum' + envtest: name: Envtest (API-level tests) runs-on: ubuntu-latest @@ -82,12 +106,13 @@ jobs: kind: name: Kind (full stack tests) runs-on: ubuntu-latest - # Run on main branch, nightly, or manual trigger + needs: [envtest, check-changes] + # Run on PRs touching relevant paths, push to main, nightly, or manual trigger if: | + (github.event_name == 'pull_request' && needs.check-changes.outputs.relevant == 'true') || github.ref == 'refs/heads/main' || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.run_kind_tests == 'true') - needs: envtest # Only run if envtest passes steps: - name: Checkout uses: actions/checkout@v4 From 464ef4d0664621c0f520ddde1dce4fb7451c3576 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 23 Feb 2026 12:45:03 -0800 Subject: [PATCH 8/9] test: address review findings on integration test infrastructure --- .github/workflows/integration-tests.yml | 11 +---- Makefile | 1 - docs/testing.md | 12 ++--- hack/test-kind.sh | 6 ++- internal/controller/core/site_envtest_test.go | 44 ++++++------------- 5 files changed, 24 insertions(+), 50 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c9b7f172..1792b7c5 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -97,7 +97,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage.out flags: envtest @@ -138,15 +138,6 @@ jobs: - name: Create kind cluster run: make kind-create KIND_CLUSTER_NAME=${{ env.KIND_CLUSTER_NAME }} - - name: Build operator image - run: | - make build - docker build -t controller:latest . - - - name: Load image into kind - run: | - kind load docker-image controller:latest --name ${{ env.KIND_CLUSTER_NAME }} - - name: Run integration tests run: | make test-kind KIND_CLUSTER_NAME=${{ env.KIND_CLUSTER_NAME }} diff --git a/Makefile b/Makefile index 8658dd5e..c3fb46ca 100644 --- a/Makefile +++ b/Makefile @@ -129,7 +129,6 @@ cov: ## Show the coverage report at the function level. ##@ Integration Testing KIND_CLUSTER_NAME ?= team-operator-test -KIND_VERSION ?= 1.29.x .PHONY: kind-create kind-create: ## Create a kind cluster for integration testing. diff --git a/docs/testing.md b/docs/testing.md index 3fa35ab0..55dd9d16 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,17 +8,17 @@ The Team Operator uses a two-tier local integration testing strategy: ### Tier 1: Envtest (Fast API Tests) -**What it is:** Envtest uses a lightweight, embedded Kubernetes API server (etcd + kube-apiserver) to test controller logic without a full cluster. +**What it is:** Envtest uses a lightweight, embedded Kubernetes API server (etcd + kube-apiserver) to test CRD schema and API storage without a full cluster or running controller. -**When to use:** For testing controller reconciliation logic, CRD validation, and API interactions. +**When to use:** For testing CRD schema validation and API storage (no controller reconciler is started in the test environment). **Execution time:** Seconds **What it tests:** - CRD schema validation -- Controller reconciliation logic -- Resource creation and updates -- Status updates +- API object creation and storage +- Resource serialization/deserialization +- Basic CRUD operations via the API ### Tier 2: Kind Cluster (Full Stack Tests) @@ -141,7 +141,7 @@ Example test file: `internal/controller/core/site_envtest_test.go` ```go var _ = Describe("Site Controller (envtest)", func() { Context("When creating a Site CR", func() { - It("Should create child resources", func() { + It("Should be able to create and retrieve a Site CR", func() { // Test code using k8sClient from suite_test.go }) }) diff --git a/hack/test-kind.sh b/hack/test-kind.sh index 4f0264de..63d95012 100755 --- a/hack/test-kind.sh +++ b/hack/test-kind.sh @@ -310,7 +310,9 @@ EOF # Cleanup kubectl delete site "${site_name}" -n "${test_namespace}" --ignore-not-found - log_info "Site CR cleaned up" + kubectl delete connect "${site_name}" -n "${test_namespace}" --ignore-not-found + kubectl delete workbench "${site_name}" -n "${test_namespace}" --ignore-not-found + log_info "Site and child CRs cleaned up" } # Test: Check operator logs for errors @@ -380,6 +382,7 @@ main() { wait_for_operator test_crds_installed test_operator_logs + test_reconciliation else # No Helm chart: install CRDs directly and skip operator deployment install_crds @@ -388,7 +391,6 @@ main() { fi test_create_site - test_reconciliation log_info "" log_info "==========================================" diff --git a/internal/controller/core/site_envtest_test.go b/internal/controller/core/site_envtest_test.go index 15cb02eb..7b328ffc 100644 --- a/internal/controller/core/site_envtest_test.go +++ b/internal/controller/core/site_envtest_test.go @@ -4,8 +4,6 @@ package core import ( - "time" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -19,10 +17,6 @@ import ( ) var _ = Describe("Site Controller (envtest)", func() { - const ( - timeout = time.Second * 30 - interval = time.Millisecond * 250 - ) Context("When creating a Site CR", func() { It("Should be able to create and retrieve a Site CR", func() { @@ -72,10 +66,7 @@ var _ = Describe("Site Controller (envtest)", func() { By("Verifying the Site CR was created") siteKey := types.NamespacedName{Name: siteName, Namespace: testNamespace} createdSite := &corev1beta1.Site{} - Eventually(func() bool { - err := k8sClient.Get(ctx, siteKey, createdSite) - return err == nil - }, timeout, interval).Should(BeTrue()) + Expect(k8sClient.Get(ctx, siteKey, createdSite)).To(Succeed()) Expect(createdSite.Name).To(Equal(siteName)) Expect(createdSite.Namespace).To(Equal(testNamespace)) @@ -102,20 +93,17 @@ var _ = Describe("Site Controller (envtest)", func() { }, } Expect(k8sClient.Create(ctx, connect)).To(Succeed()) + DeferCleanup(func() { + Expect(k8sClient.Delete(ctx, connect)).To(Succeed()) + }) By("Verifying the Connect CR was created") connectKey := types.NamespacedName{Name: connectName, Namespace: testNamespace} createdConnect := &corev1beta1.Connect{} - Eventually(func() bool { - err := k8sClient.Get(ctx, connectKey, createdConnect) - return err == nil - }, timeout, interval).Should(BeTrue()) + Expect(k8sClient.Get(ctx, connectKey, createdConnect)).To(Succeed()) Expect(createdConnect.Name).To(Equal(connectName)) Expect(createdConnect.Spec.Replicas).To(Equal(1)) - - By("Cleaning up") - Expect(k8sClient.Delete(ctx, connect)).To(Succeed()) }) }) @@ -138,20 +126,17 @@ var _ = Describe("Site Controller (envtest)", func() { }, } Expect(k8sClient.Create(ctx, workbench)).To(Succeed()) + DeferCleanup(func() { + Expect(k8sClient.Delete(ctx, workbench)).To(Succeed()) + }) By("Verifying the Workbench CR was created") workbenchKey := types.NamespacedName{Name: workbenchName, Namespace: testNamespace} createdWorkbench := &corev1beta1.Workbench{} - Eventually(func() bool { - err := k8sClient.Get(ctx, workbenchKey, createdWorkbench) - return err == nil - }, timeout, interval).Should(BeTrue()) + Expect(k8sClient.Get(ctx, workbenchKey, createdWorkbench)).To(Succeed()) Expect(createdWorkbench.Name).To(Equal(workbenchName)) Expect(createdWorkbench.Spec.Replicas).To(Equal(1)) - - By("Cleaning up") - Expect(k8sClient.Delete(ctx, workbench)).To(Succeed()) }) }) @@ -174,20 +159,17 @@ var _ = Describe("Site Controller (envtest)", func() { }, } Expect(k8sClient.Create(ctx, pm)).To(Succeed()) + DeferCleanup(func() { + Expect(k8sClient.Delete(ctx, pm)).To(Succeed()) + }) By("Verifying the PackageManager CR was created") pmKey := types.NamespacedName{Name: pmName, Namespace: testNamespace} createdPM := &corev1beta1.PackageManager{} - Eventually(func() bool { - err := k8sClient.Get(ctx, pmKey, createdPM) - return err == nil - }, timeout, interval).Should(BeTrue()) + Expect(k8sClient.Get(ctx, pmKey, createdPM)).To(Succeed()) Expect(createdPM.Name).To(Equal(pmName)) Expect(createdPM.Spec.Replicas).To(Equal(1)) - - By("Cleaning up") - Expect(k8sClient.Delete(ctx, pm)).To(Succeed()) }) }) }) From 193d70a655024a13b3534a3e89197d2b78cfb2a9 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 23 Feb 2026 13:07:34 -0800 Subject: [PATCH 9/9] feat: add persistent kind dev loop with kind-setup/kind-test/kind-teardown --- Makefile | 14 ++++++++ README.md | 35 ++++++++++++++++++ docs/testing.md | 33 +++++++++++++++++ hack/test-kind.sh | 90 +++++++++++++++++++++++++++++++++-------------- 4 files changed, 146 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index c3fb46ca..7d3c0118 100644 --- a/Makefile +++ b/Makefile @@ -147,6 +147,20 @@ kind-delete: ## Delete the kind cluster. kind-load-image: docker-build ## Load the operator image into kind cluster. kind load docker-image $(IMG) --name $(KIND_CLUSTER_NAME) +.PHONY: kind-setup +kind-setup: kind-create docker-build helm-generate ## Set up kind cluster and deploy operator (run once, or after code changes to reload). + @echo "Setting up kind cluster '$(KIND_CLUSTER_NAME)'..." + ./hack/test-kind.sh $(KIND_CLUSTER_NAME) setup + +.PHONY: kind-test +kind-test: ## Run integration tests against an existing kind cluster (requires kind-setup first). + ./hack/test-kind.sh $(KIND_CLUSTER_NAME) test + +.PHONY: kind-teardown +kind-teardown: ## Tear down the kind cluster and remove all test resources. + ./hack/test-kind.sh $(KIND_CLUSTER_NAME) teardown + kind delete cluster --name $(KIND_CLUSTER_NAME) || true + .PHONY: test-kind test-kind: kind-create docker-build helm-generate ## Build operator image and run integration tests on a kind cluster. @echo "Running integration tests on kind cluster '$(KIND_CLUSTER_NAME)'..." diff --git a/README.md b/README.md index 3790e6be..f0ae0a71 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,41 @@ just helm-install # Install via Helm just helm-uninstall # Uninstall via Helm ``` +### Testing + +**Unit tests** (fast, no cluster required): + +```bash +make go-test +``` + +**Integration tests** — two workflows: + +*One-shot (CI-style):* creates a cluster, runs all tests, and tears everything down. + +```bash +make test-kind # create → deploy → test → destroy +make test-kind-full # same, but forces a clean cluster first +``` + +*Dev loop (recommended for iterative development):* keep the cluster running between test runs. + +```bash +# One-time setup: create cluster and deploy operator +make kind-setup + +# After making code changes, reload the image and re-deploy +make kind-setup + +# Run tests against the running cluster +make kind-test + +# When done for the day +make kind-teardown +``` + +See [docs/testing.md](docs/testing.md) for full details. + ## Configuration The Site CR defines a complete Posit Team deployment. Secrets and licenses are managed automatically through cloud provider integration (AWS Secrets Manager or Azure Key Vault) - configured during PTD bootstrap. diff --git a/docs/testing.md b/docs/testing.md index 55dd9d16..2b981c63 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -158,6 +158,39 @@ The kind test script: 5. Validates reconciliation 6. Cleans up +#### Development Loop + +For iterative development, keep the kind cluster running between test runs instead of recreating it each time. + +**Initial setup** (run once): + +```bash +make kind-setup +``` + +This creates the cluster, builds the operator image, loads it into kind, and deploys it via Helm. + +**After making code changes**, rebuild and redeploy: + +```bash +make kind-setup # rebuilds image, reloads into kind, helm upgrade +``` + +**Run tests** against the live cluster: + +```bash +make kind-test +``` + +**Tear down** when done: + +```bash +make kind-teardown # removes Helm release and namespaces + # (also deletes the kind cluster) +``` + +This workflow is significantly faster than `make test-kind` for iterative development because it skips cluster creation and deletion on every run. + ## CI Integration Integration tests run automatically via GitHub Actions: diff --git a/hack/test-kind.sh b/hack/test-kind.sh index 63d95012..49c9ff15 100755 --- a/hack/test-kind.sh +++ b/hack/test-kind.sh @@ -10,11 +10,23 @@ # 2. Create and reconcile Site CRs # 3. Clean up properly # -# Usage: ./hack/test-kind.sh [CLUSTER_NAME] +# Usage: +# ./hack/test-kind.sh [mode] +# +# Modes: +# full (default) Create cluster, deploy, test, and clean up +# setup Deploy operator to an existing cluster (or create if needed) +# test Run tests against an already-deployed cluster +# teardown Clean up namespaces and Helm release (cluster remains) set -euo pipefail CLUSTER_NAME="${1:-team-operator-test}" +MODE="${2:-full}" +# Strip --mode= prefix if provided as --mode=setup +if [[ "${MODE}" == --mode=* ]]; then + MODE="${MODE#--mode=}" +fi NAMESPACE="posit-team-system" RELEASE_NAME="team-operator" CHART_DIR="dist/chart" @@ -367,35 +379,61 @@ cleanup() { # Main test runner main() { - log_info "Starting integration tests on kind cluster '${CLUSTER_NAME}'..." + log_info "Starting integration tests on kind cluster '${CLUSTER_NAME}' (mode: ${MODE})..." check_prerequisites ensure_context - # Run tests with cleanup on exit - trap cleanup EXIT - - # Only run operator deployment tests if chart exists - if [[ -d "${CHART_DIR}" ]]; then - # Let Helm manage CRD installation — pre-installing via kubectl causes ownership conflicts - deploy_operator - wait_for_operator - test_crds_installed - test_operator_logs - test_reconciliation - else - # No Helm chart: install CRDs directly and skip operator deployment - install_crds - test_crds_installed - log_warn "Helm chart not found at ${CHART_DIR}, skipping operator deployment tests" - fi - - test_create_site - - log_info "" - log_info "==========================================" - log_info "All integration tests passed!" - log_info "==========================================" + case "${MODE}" in + setup) + if [[ -d "${CHART_DIR}" ]]; then + deploy_operator + wait_for_operator + else + install_crds + log_warn "Helm chart not found at ${CHART_DIR}, skipping operator deployment" + fi + log_info "Kind cluster is ready. Run 'make kind-test' to execute tests." + ;; + test) + test_crds_installed + if [[ -d "${CHART_DIR}" ]]; then + test_operator_logs + test_reconciliation + fi + test_create_site + log_info "" + log_info "==========================================" + log_info "All integration tests passed!" + log_info "==========================================" + ;; + teardown) + cleanup + ;; + full) + trap cleanup EXIT + if [[ -d "${CHART_DIR}" ]]; then + deploy_operator + wait_for_operator + test_crds_installed + test_operator_logs + test_reconciliation + else + install_crds + test_crds_installed + log_warn "Helm chart not found at ${CHART_DIR}, skipping operator deployment tests" + fi + test_create_site + log_info "" + log_info "==========================================" + log_info "All integration tests passed!" + log_info "==========================================" + ;; + *) + log_error "Unknown mode: ${MODE}. Valid modes: setup, test, teardown, full" + exit 1 + ;; + esac } main "$@"