diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..bee0090 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,47 @@ +name: E2E Integration Tests + +on: + pull_request: + branches: ["main", "release/**"] + schedule: + - cron: '0 2 * * *' # Runs daily at 2:00 AM UTC + +jobs: + e2e-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Install Dependencies (Kind, Kuttl, Dig) + run: | + # Install dig + sudo apt-get update && sudo apt-get install -y dnsutils + + # Install Kind + curl -sSLo ./kind https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + # Install Kuttl + curl -sSLo kubectl-kuttl https://github.com/kudobuilder/kuttl/releases/download/v0.15.0/kubectl-kuttl_0.15.0_linux_x86_64 + chmod +x kubectl-kuttl + sudo mv kubectl-kuttl /usr/local/bin/ + + - name: Prepare STACKIT Service Account Key + run: | + # Write the secret from GitHub Actions into a temporary file so the Makefile can mount it + echo '${{ secrets.STACKIT_SERVICE_ACCOUNT_KEY }}' > /tmp/sa.json + + - name: Run E2E Tests + env: + PROJECT_ID: ${{ secrets.STACKIT_PROJECT_ID }} + ZONE_NAME: ${{ secrets.STACKIT_ZONE_NAME }} + AUTH_KEY_PATH: /tmp/sa.json + run: | + make test-e2e-local \ No newline at end of file diff --git a/Makefile b/Makefile index 21b35b8..20421a3 100644 --- a/Makefile +++ b/Makefile @@ -80,3 +80,57 @@ license-check: $(GO_LICENSES) reports ## Check licenses against code. .PHONY: license-report license-report: $(GO_LICENSES) reports ## Create licenses report against code. $(GO_LICENSES) report --include_tests --ignore $(LICENCES_IGNORE_LIST) ./... > ./reports/licenses/licenses-list.csv + +# ============================================================================== +# E2E Local Testing +# ============================================================================== + +E2E_TMP_DIR = tests/e2e-tmp + +.PHONY: build-linux +build-linux: + CGO_ENABLED=0 GOOS=linux GOARCH=$(shell go env GOARCH) go build -ldflags "-s -w" -o ./external-dns-stackit-webhook -v cmd/webhook/main.go + +.PHONY: docker-build-e2e +docker-build-e2e: build-linux + docker build -t stackitcloud/external-dns-stackit-webhook:e2e -f Dockerfile . + rm ./external-dns-stackit-webhook # Clean up the binary after build + +# Run this to test the webhook locally +test-e2e-local: docker-build-e2e + @if [ -z "$(PROJECT_ID)" ] || [ -z "$(ZONE_NAME)" ] || [ -z "$(AUTH_KEY_PATH)" ]; then \ + echo "Error: Missing PROJECT_ID, ZONE_NAME, or AUTH_KEY_PATH environment variables."; \ + exit 1; \ + fi + @echo "=> Creating Kind cluster..." + kind create cluster --name stackit-e2e || true + @echo "=> Loading image into Kind..." + kind load docker-image stackitcloud/external-dns-stackit-webhook:e2e --name stackit-e2e + @echo "=> Setting up STACKIT credentials..." + kubectl create secret generic external-dns-stackit-webhook \ + --from-file=sa.json=$(AUTH_KEY_PATH) \ + --dry-run=client -o yaml | kubectl apply -f - + @echo "=> Preparing test manifests..." + rm -rf $(E2E_TMP_DIR) + cp -r tests/e2e $(E2E_TMP_DIR) + find $(E2E_TMP_DIR) -type f -name "*.yaml" -exec sed -i.bak "s/\$${PROJECT_ID}/$(PROJECT_ID)/g" {} + + find $(E2E_TMP_DIR) -type f -name "*.yaml" -exec sed -i.bak "s/\$${ZONE_NAME}/$(ZONE_NAME)/g" {} + + find $(E2E_TMP_DIR) -type f -name "*.bak" -delete + + @echo "=> Deploying ExternalDNS and Webhook..." + kubectl apply -f $(E2E_TMP_DIR)/deploy/external-dns.yaml + kubectl wait --for=condition=available --timeout=60s deployment/external-dns + + @echo "=> Running Kuttl Tests..." + cd $(E2E_TMP_DIR) && \ + kubectl kuttl test; \ + RET=$$?; \ + echo "=> Cleaning up local test environment..."; \ + kind delete cluster --name stackit-e2e; \ + cd ../.. && rm -rf $(E2E_TMP_DIR); \ + exit $$RET + +.PHONY: clean-e2e-local +clean-e2e-local: + kind delete cluster --name stackit-e2e + rm -rf $(E2E_TMP_DIR) \ No newline at end of file diff --git a/README.md b/README.md index 2ca0157..9986654 100644 --- a/README.md +++ b/README.md @@ -361,3 +361,16 @@ Test the code: ```bash make test ``` + +### E2E Testing + +End-to-end integration tests are orchestrated using [Kuttl](https://kuttl.dev/) and run against a dynamically generated [Kind](https://kind.sigs.k8s.io/) cluster. The test suite builds the webhook locally, deploys it alongside ExternalDNS, and directly verifies real DNS record propagation (A, AAAA, CNAME) against the STACKIT authoritative nameservers. + +To run the E2E test suite locally, ensure you have Docker and `kind` installed, then execute: + +```bash +make test-e2e-local \ + PROJECT_ID="your-project-id" \ + ZONE_NAME="your.test.zone.cloud" \ + AUTH_KEY_PATH="/absolute/path/to/your/sa.json" +``` diff --git a/tests/e2e/deploy/external-dns.yaml b/tests/e2e/deploy/external-dns.yaml new file mode 100644 index 0000000..e6a40a3 --- /dev/null +++ b/tests/e2e/deploy/external-dns.yaml @@ -0,0 +1,81 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-dns +rules: + - apiGroups: [""] + resources: ["services","endpoints","pods","nodes"] + verbs: ["get","watch","list"] + - apiGroups: ["extensions","networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: + - kind: ServiceAccount + name: external-dns + namespace: default +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: external-dns + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + volumes: + - name: stackit-sa-key + secret: + secretName: external-dns-stackit-webhook + items: + - key: sa.json + path: sa.json + containers: + - name: external-dns + image: registry.k8s.io/external-dns/external-dns:v0.14.0 + args: + - --log-level=info + - --interval=10s + - --source=service + - --policy=sync + - --provider=webhook + - --domain-filter=${ZONE_NAME} + - --txt-prefix=txt- + - name: webhook + image: stackitcloud/external-dns-stackit-webhook:e2e + imagePullPolicy: Never # Forces the use of our sideloaded local build + args: + - --project-id=${PROJECT_ID} + - --log-level=debug + ports: + - name: http + containerPort: 8888 + env: + - name: AUTH_KEY_PATH + value: /var/run/secrets/stackit/sa.json + volumeMounts: + - name: stackit-sa-key + mountPath: /var/run/secrets/stackit + readOnly: true \ No newline at end of file diff --git a/tests/e2e/kuttl-test.yaml b/tests/e2e/kuttl-test.yaml new file mode 100644 index 0000000..eb1cdd2 --- /dev/null +++ b/tests/e2e/kuttl-test.yaml @@ -0,0 +1,5 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestSuite +testDirs: + - test-suite +timeout: 120 \ No newline at end of file diff --git a/tests/e2e/test-suite/record-lifecycle/00-assert.yaml b/tests/e2e/test-suite/record-lifecycle/00-assert.yaml new file mode 100644 index 0000000..5cbca51 --- /dev/null +++ b/tests/e2e/test-suite/record-lifecycle/00-assert.yaml @@ -0,0 +1,39 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +commands: + - script: | + check_record() { + RECORD_TYPE=$1 + RECORD_NAME=$2 + EXPECTED_RESULT=$3 + ZONE=$4 + + AUTH_NS=$(dig +short NS "$ZONE" | head -n 1) + if [ -z "$AUTH_NS" ]; then + echo "ERROR: Could not determine authoritative nameserver for $ZONE" + return 1 + fi + + RESULT=$(dig "@$AUTH_NS" -t "$RECORD_TYPE" +short "$RECORD_NAME") + echo "DEBUG: Dig Result for $RECORD_NAME: '$RESULT'" + + if echo "$RESULT" | grep -q "$EXPECTED_RESULT"; then + echo "SUCCESS: $RECORD_TYPE record $RECORD_NAME verified!" + return 0 + fi + + echo "FAILED: Condition not met for $RECORD_NAME." + return 1 + } + + # Run all checks. If any fail, the chain short-circuits and we exit 1 to trigger a Kuttl retry. + check_record A "e2e-a.${ZONE_NAME}" "10.0.0.1" "${ZONE_NAME}" && \ + check_record AAAA "e2e-aaaa.${ZONE_NAME}" "2001:db8::1" "${ZONE_NAME}" && \ + check_record CNAME "e2e-cname.${ZONE_NAME}" "e2e-a.${ZONE_NAME}." "${ZONE_NAME}" + + if [ $? -ne 0 ]; then + sleep 5 + exit 1 + fi + exit 0 \ No newline at end of file diff --git a/tests/e2e/test-suite/record-lifecycle/00-service.yaml b/tests/e2e/test-suite/record-lifecycle/00-service.yaml new file mode 100644 index 0000000..d38da79 --- /dev/null +++ b/tests/e2e/test-suite/record-lifecycle/00-service.yaml @@ -0,0 +1,38 @@ +# 1. Triggers an A Record (IPv4) +apiVersion: v1 +kind: Service +metadata: + name: test-service-a + annotations: + external-dns.alpha.kubernetes.io/hostname: "e2e-a.${ZONE_NAME}" + external-dns.alpha.kubernetes.io/target: "10.0.0.1" +spec: + type: LoadBalancer + ports: + - port: 80 +--- +# 2. Triggers an AAAA Record (IPv6) +apiVersion: v1 +kind: Service +metadata: + name: test-service-aaaa + annotations: + external-dns.alpha.kubernetes.io/hostname: "e2e-aaaa.${ZONE_NAME}" + external-dns.alpha.kubernetes.io/target: "2001:db8::1" +spec: + type: LoadBalancer + ports: + - port: 80 +--- +# 3. Triggers a CNAME Record (Hostname) +apiVersion: v1 +kind: Service +metadata: + name: test-service-cname + annotations: + external-dns.alpha.kubernetes.io/hostname: "e2e-cname.${ZONE_NAME}" + external-dns.alpha.kubernetes.io/target: "e2e-a.${ZONE_NAME}" +spec: + type: LoadBalancer + ports: + - port: 80 \ No newline at end of file diff --git a/tests/e2e/test-suite/record-lifecycle/01-assert.yaml b/tests/e2e/test-suite/record-lifecycle/01-assert.yaml new file mode 100644 index 0000000..3081bf0 --- /dev/null +++ b/tests/e2e/test-suite/record-lifecycle/01-assert.yaml @@ -0,0 +1,37 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +commands: + - script: | + check_deleted() { + RECORD_TYPE=$1 + RECORD_NAME=$2 + ZONE=$3 + + AUTH_NS=$(dig +short NS "$ZONE" | head -n 1) + if [ -z "$AUTH_NS" ]; then + echo "ERROR: Could not determine authoritative nameserver for $ZONE" + return 1 + fi + + RESULT=$(dig "@$AUTH_NS" -t "$RECORD_TYPE" +short "$RECORD_NAME") + echo "DEBUG: Dig Result for $RECORD_NAME: '$RESULT'" + + if [ -z "$RESULT" ]; then + echo "SUCCESS: $RECORD_TYPE record $RECORD_NAME successfully deleted!" + return 0 + fi + + echo "FAILED: $RECORD_NAME still exists." + return 1 + } + + check_deleted A "e2e-a.${ZONE_NAME}" "${ZONE_NAME}" && \ + check_deleted AAAA "e2e-aaaa.${ZONE_NAME}" "${ZONE_NAME}" && \ + check_deleted CNAME "e2e-cname.${ZONE_NAME}" "${ZONE_NAME}" + + if [ $? -ne 0 ]; then + sleep 5 + exit 1 + fi + exit 0 \ No newline at end of file diff --git a/tests/e2e/test-suite/record-lifecycle/01-delete.yaml b/tests/e2e/test-suite/record-lifecycle/01-delete.yaml new file mode 100644 index 0000000..5cba986 --- /dev/null +++ b/tests/e2e/test-suite/record-lifecycle/01-delete.yaml @@ -0,0 +1,12 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: v1 + kind: Service + name: test-service-a + - apiVersion: v1 + kind: Service + name: test-service-aaaa + - apiVersion: v1 + kind: Service + name: test-service-cname \ No newline at end of file