diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..1792b7c5 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,179 @@ +# 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 every PR that touches +# relevant paths (api/, internal/, Dockerfile, etc.), on push to main, +# nightly, and on manual dispatch. + +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: + KIND_VERSION: 'v0.23.0' + 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 + 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@v5 + with: + files: coverage.out + flags: envtest + fail_ci_if_error: false + + kind: + name: Kind (full stack tests) + runs-on: ubuntu-latest + 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') + 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: 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/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 c52e4c0a..7d3c0118 100644 --- a/Makefile +++ b/Makefile @@ -119,19 +119,71 @@ 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. $(SED) -i '/team-operator\/client-go/d' coverage.out go tool cover -func coverage.out +##@ Integration Testing + +KIND_CLUSTER_NAME ?= team-operator-test + +.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: 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)'..." + ./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 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/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 new file mode 100644 index 00000000..2b981c63 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,277 @@ +# 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 CRD schema and API storage without a full cluster or running controller. + +**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 +- API object creation and storage +- Resource serialization/deserialization +- Basic CRUD operations via the API + +### 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 be able to create and retrieve a Site CR", 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 + +#### 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: + +| 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..49c9ff15 --- /dev/null +++ b/hack/test-kind.sh @@ -0,0 +1,439 @@ +#!/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 [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" +# 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 +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 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 - + + # 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 "controllerManager.container.image.repository=${image_repo}" \ + --set "controllerManager.container.image.tag=${image_tag}" \ + --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}-controller-manager" -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 </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 + 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 +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..." + + # Uninstall Helm release + helm uninstall "${RELEASE_NAME}" -n "${NAMESPACE}" --ignore-not-found || true + + # Delete namespace + kubectl delete namespace "${NAMESPACE}" --ignore-not-found || true + kubectl delete namespace "posit-team" --ignore-not-found || true + + log_info "Cleanup completed" +} + +# Main test runner +main() { + log_info "Starting integration tests on kind cluster '${CLUSTER_NAME}' (mode: ${MODE})..." + + check_prerequisites + ensure_context + + 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 "$@" 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") diff --git a/internal/controller/core/site_envtest_test.go b/internal/controller/core/site_envtest_test.go new file mode 100644 index 00000000..7b328ffc --- /dev/null +++ b/internal/controller/core/site_envtest_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +package core + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" + "github.com/posit-dev/team-operator/api/product" +) + +var _ = Describe("Site Controller (envtest)", func() { + + Context("When creating a Site CR", 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{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + } + // Namespace might already exist from another test + err := k8sClient.Create(ctx, ns) + if err != nil && !isAlreadyExistsError(err) { + Expect(err).NotTo(HaveOccurred()) + } + + By("Creating a Site CR") + siteName := "test-site-envtest" + site := &corev1beta1.Site{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "core.posit.team/v1beta1", + Kind: "Site", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: siteName, + Namespace: testNamespace, + }, + Spec: corev1beta1.SiteSpec{ + WorkloadSecret: corev1beta1.SecretConfig{ + VaultName: "workload-vault", + Type: product.SiteSecretTest, + }, + MainDatabaseCredentialSecret: corev1beta1.SecretConfig{ + VaultName: "test-vault", + Type: product.SiteSecretTest, + }, + Flightdeck: corev1beta1.InternalFlightdeckSpec{ + Image: "test-image:latest", + }, + }, + } + 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} + createdSite := &corev1beta1.Site{} + Expect(k8sClient.Get(ctx, siteKey, createdSite)).To(Succeed()) + + Expect(createdSite.Name).To(Equal(siteName)) + Expect(createdSite.Namespace).To(Equal(testNamespace)) + }) + }) + + Context("When testing Connect CRD", func() { + It("Should be able to create a Connect resource directly", func() { + testNamespace := "posit-team" + connectName := "test-connect-envtest" + + connect := &corev1beta1.Connect{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "core.posit.team/v1beta1", + Kind: "Connect", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: connectName, + Namespace: testNamespace, + }, + Spec: corev1beta1.ConnectSpec{ + Debug: false, + Replicas: 1, + }, + } + 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{} + Expect(k8sClient.Get(ctx, connectKey, createdConnect)).To(Succeed()) + + Expect(createdConnect.Name).To(Equal(connectName)) + Expect(createdConnect.Spec.Replicas).To(Equal(1)) + }) + }) + + Context("When testing Workbench CRD", func() { + It("Should be able to create a Workbench resource directly", func() { + testNamespace := "posit-team" + workbenchName := "test-workbench-envtest" + + workbench := &corev1beta1.Workbench{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "core.posit.team/v1beta1", + Kind: "Workbench", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: workbenchName, + Namespace: testNamespace, + }, + Spec: corev1beta1.WorkbenchSpec{ + Replicas: 1, + }, + } + 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{} + Expect(k8sClient.Get(ctx, workbenchKey, createdWorkbench)).To(Succeed()) + + Expect(createdWorkbench.Name).To(Equal(workbenchName)) + Expect(createdWorkbench.Spec.Replicas).To(Equal(1)) + }) + }) + + Context("When testing PackageManager CRD", func() { + It("Should be able to create a PackageManager resource directly", func() { + testNamespace := "posit-team" + pmName := "test-pm-envtest" + + pm := &corev1beta1.PackageManager{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "core.posit.team/v1beta1", + Kind: "PackageManager", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: pmName, + Namespace: testNamespace, + }, + Spec: corev1beta1.PackageManagerSpec{ + Replicas: 1, + }, + } + 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{} + Expect(k8sClient.Get(ctx, pmKey, createdPM)).To(Succeed()) + + Expect(createdPM.Name).To(Equal(pmName)) + Expect(createdPM.Spec.Replicas).To(Equal(1)) + }) + }) +}) + +// Helper to check if error is "already exists" +func isAlreadyExistsError(err error) bool { + if err == nil { + return false + } + return client.IgnoreAlreadyExists(err) == nil +} diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 4ec2cc64..f87f822f 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -4,14 +4,15 @@ package core import ( - "fmt" + "context" "path/filepath" - "runtime" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -20,6 +21,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" corev1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" + keycloakv2alpha1 "github.com/posit-dev/team-operator/api/keycloak/v2alpha1" + "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" + secretsstorev1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" //+kubebuilder:scaffold:imports ) @@ -29,6 +33,8 @@ import ( var cfg *rest.Config var k8sClient client.Client var testEnv *envtest.Environment +var ctx context.Context +var cancel context.CancelFunc func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -37,21 +43,18 @@ func TestControllers(t *testing.T) { } var _ = BeforeSuite(func() { - Skip("not implemented yet") logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.TODO()) + By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + filepath.Join("..", "..", "..", "hack", "keycloak", "crds"), + filepath.Join("..", "..", "..", "hack", "traefik", "crds"), + }, ErrorIfCRDPathMissing: true, - - // The BinaryAssetsDirectory is only required if you want to run the tests directly - // without call the makefile target test. If not informed it will look for the - // default path defined in controller-runtime which is /usr/local/kubebuilder/. - // Note that you must have the required binaries setup under the bin directory to perform - // the tests directly. When we run make test it will be setup and used automatically. - BinaryAssetsDirectory: filepath.Join("..", "..", "..", "bin", "k8s", - fmt.Sprintf("1.28.3-%s-%s", runtime.GOOS, runtime.GOARCH)), } var err error @@ -60,19 +63,36 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) + // Register all required schemes err = corev1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = keycloakv2alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = v1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = secretsstorev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + //+kubebuilder:scaffold:scheme k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) + // Create the posit-team namespace for tests + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "posit-team", + }, + } + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) }) var _ = AfterSuite(func() { - Skip("not implemented yet") + cancel() By("tearing down the test environment") err := testEnv.Stop() Expect(err).NotTo(HaveOccurred())