diff --git a/.github/workflows/ai-code-review.yml b/.github/workflows/ai-code-review.yml new file mode 100644 index 0000000..49387f5 --- /dev/null +++ b/.github/workflows/ai-code-review.yml @@ -0,0 +1,211 @@ +# AI Code Review - systematic Go code analysis +# Automated static analysis for concurrency, race conditions, and code quality +name: AI Code Review + +on: + pull_request: + branches: [ main, master ] + paths: + - '**.go' + - 'go.mod' + - 'go.sum' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + ai-review: + name: AI Code Review + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + cache: true + + - name: Get changed Go files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + **/*.go + separator: ',' + + - name: Run static analysis + if: steps.changed-files.outputs.any_changed == 'true' + run: | + # Install analysis tools + go install honnef.co/go/tools/cmd/staticcheck@latest + go install github.com/securego/gosec/v2/cmd/gosec@latest + go install golang.org/x/tools/go/analysis/passes/nilness/cmd/nilness@latest + + echo "## πŸ” Static Analysis Results" > review.md + echo "" >> review.md + + # Run staticcheck + echo "### πŸ“Š Staticcheck" >> review.md + if staticcheck ./... 2>&1 | tee staticcheck.log; then + echo "βœ… No issues found" >> review.md + else + echo '```' >> review.md + cat staticcheck.log >> review.md + echo '```' >> review.md + fi + echo "" >> review.md + + # Run gosec for security issues + echo "### πŸ”’ Security Analysis (gosec)" >> review.md + if gosec -fmt=text ./... 2>&1 | tee gosec.log; then + echo "βœ… No security issues found" >> review.md + else + echo '```' >> review.md + cat gosec.log >> review.md + echo '```' >> review.md + fi + echo "" >> review.md + + # Run go vet + echo "### πŸ”§ Go Vet" >> review.md + if go vet ./... 2>&1 | tee govet.log; then + echo "βœ… No issues found" >> review.md + else + echo '```' >> review.md + cat govet.log >> review.md + echo '```' >> review.md + fi + echo "" >> review.md + + - name: Race detector check + if: steps.changed-files.outputs.any_changed == 'true' + run: | + echo "### 🏁 Race Detector" >> review.md + if go test -race -short ./... 2>&1 | tee race.log; then + echo "βœ… No race conditions detected" >> review.md + else + echo '```' >> review.md + cat race.log >> review.md + echo '```' >> review.md + fi + echo "" >> review.md + + - name: Concurrency analysis + if: steps.changed-files.outputs.any_changed == 'true' + run: | + echo "### πŸš€ Concurrency Patterns Analysis" >> review.md + echo "" >> review.md + + # Find all goroutines + echo "#### Goroutines found:" >> review.md + echo '```' >> review.md + grep -rn "go func\|go [a-zA-Z]" --include="*.go" . | head -50 >> review.md || echo "None found" >> review.md + echo '```' >> review.md + echo "" >> review.md + + # Find mutex usage + echo "#### Mutex usage:" >> review.md + echo '```' >> review.md + grep -rn "sync.Mutex\|sync.RWMutex" --include="*.go" . | head -50 >> review.md || echo "None found" >> review.md + echo '```' >> review.md + echo "" >> review.md + + # Find context usage + echo "#### Context usage:" >> review.md + echo '```' >> review.md + grep -rn "context.With\|context.Background\|context.TODO" --include="*.go" . | head -50 >> review.md || echo "None found" >> review.md + echo '```' >> review.md + echo "" >> review.md + + # Find defer patterns + echo "#### Defer patterns:" >> review.md + echo '```' >> review.md + grep -rn "defer.*Unlock\|defer.*Done\|defer.*cancel" --include="*.go" . | head -50 >> review.md || echo "None found" >> review.md + echo '```' >> review.md + echo "" >> review.md + + - name: Error handling analysis + if: steps.changed-files.outputs.any_changed == 'true' + run: | + echo "### ⚠️ Error Handling Patterns" >> review.md + echo "" >> review.md + + # Find error returns without checks + echo "#### Potential unchecked errors:" >> review.md + echo '```' >> review.md + grep -rn "_, err :=\|_, err =" --include="*.go" . | grep -v "if err" | head -30 >> review.md || echo "None found" >> review.md + echo '```' >> review.md + echo "" >> review.md + + # Find nil checks + echo "#### Nil checks:" >> review.md + echo '```' >> review.md + grep -rn "if .* == nil\|if .* != nil" --include="*.go" . | head -30 >> review.md || echo "None found" >> review.md + echo '```' >> review.md + echo "" >> review.md + + - name: Post review comment + if: steps.changed-files.outputs.any_changed == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + let reviewContent = ''; + try { + reviewContent = fs.readFileSync('review.md', 'utf8'); + } catch (error) { + reviewContent = 'Could not generate review content'; + } + + const changedFiles = process.env.CHANGED_FILES || 'N/A'; + + const header = '## πŸ€– AI Code Review\n\n' + + '**Changed files:** ' + changedFiles + '\n\n' + + 'This automated review checks for:\n' + + '- πŸ”’ Race conditions and concurrency issues\n' + + '- πŸ”— Potential deadlocks\n' + + '- ⚠️ Error handling patterns\n' + + '- πŸš€ Goroutine lifecycle management\n' + + '- πŸ“‹ Context usage\n' + + '- 🎯 Nil safety\n\n'; + + const footer = '\n\n---\n' + + '*Automated analysis using static analysis tools and pattern matching*'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: header + reviewContent + footer + }); + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Upload review artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: code-review-results + path: | + review.md + *.log + retention-days: 30 + + - name: Summary + if: always() + run: | + echo "### AI Code Review Completed βœ…" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f review.md ]; then + cat review.md >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..b880fc3 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,85 @@ +# Build and push yanet-operator Docker images to Docker Hub and GHCR +# Triggered on: push to main/master, version tags (v*), PRs, and manual dispatch +name: Build and Push Docker Image + +on: + push: + branches: [ main, master ] + tags: + - 'v*' # Trigger on version tags like v0.1.0 + pull_request: + branches: [ main, master ] + workflow_dispatch: + inputs: + tag: + description: 'Image tag (leave empty for latest)' + required: false + default: '' + type: string + +env: + DOCKERHUB_REPO: yanetplatform/yanet-operator # Docker Hub repository + GHCR_REPO: ghcr.io/${{ github.repository }} # GitHub Container Registry + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + continue-on-error: true + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.GHCR_REPO }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + type=ref,event=pr + type=sha,format=short + type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ steps.meta.outputs.version }} + + - name: Image digest + run: echo "${{ steps.meta.outputs.tags }}" diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 0000000..2c067b4 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,170 @@ +# Package and publish Helm chart to Docker Hub OCI and GHCR +# Triggered on: push to main/master, version tags, and PRs +name: Helm Chart + +on: + push: + branches: [ main, master ] + tags: + - 'v*' # Version tags like v0.1.0 + - 'chart-v*' # Chart-specific tags like chart-v0.1.0 + pull_request: + branches: [ main, master ] + +env: + GHCR_REGISTRY: ghcr.io # GitHub Container Registry + DOCKERHUB_REGISTRY: registry-1.docker.io # Docker Hub OCI registry + CHART_NAME: yanet-operator + +jobs: + lint: + name: Lint Helm Chart + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Lint Helm chart + run: | + helm lint deploy/charts/yanet-operator + + - name: Template Helm chart + run: | + helm template test deploy/charts/yanet-operator --debug + + package-and-push: + name: Package and Push Helm Chart + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Log in to GitHub Container Registry + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.GHCR_REGISTRY }} --username ${{ github.actor }} --password-stdin + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | helm registry login ${{ env.DOCKERHUB_REGISTRY }} --username ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Extract chart version + id: chart_version + run: | + CHART_VERSION=$(grep '^version:' deploy/charts/yanet-operator/Chart.yaml | awk '{print $2}') + echo "version=${CHART_VERSION}" >> "$GITHUB_OUTPUT" + echo "Chart version: ${CHART_VERSION}" + + - name: Sync appVersion with version + run: | + CHART_VERSION=$(grep '^version:' deploy/charts/yanet-operator/Chart.yaml | awk '{print $2}') + sed -i "s/^appVersion:.*/appVersion: \"${CHART_VERSION}\"/" deploy/charts/yanet-operator/Chart.yaml + echo "Synced appVersion to ${CHART_VERSION}" + + - name: Package Helm chart + run: | + helm package deploy/charts/yanet-operator + + - name: Push Helm chart to GitHub Container Registry + run: | + helm push ${{ env.CHART_NAME }}-${{ steps.chart_version.outputs.version }}.tgz oci://${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }} + + - name: Push Helm chart to Docker Hub + if: github.event_name != 'pull_request' + run: | + helm push ${{ env.CHART_NAME }}-${{ steps.chart_version.outputs.version }}.tgz oci://${{ env.DOCKERHUB_REGISTRY }}/yanetplatform + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: helm-chart + path: ${{ env.CHART_NAME }}-*.tgz + + test: + name: Test Helm Chart + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + cache: true + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Set up kind + uses: helm/kind-action@v1 + with: + cluster_name: chart-testing + + - name: Build operator image + run: | + make docker-build IMG=yanet-operator:test + + - name: Load image into kind + run: | + kind load docker-image yanet-operator:test --name chart-testing + + - name: Install chart + run: | + helm install test deploy/charts/yanet-operator \ + --wait \ + --timeout 5m \ + --create-namespace \ + --namespace yanet \ + --set image.repository=yanet-operator \ + --set image.tag=test \ + --set image.pullPolicy=Never \ + --set replicaCount=1 \ + --set metrics.serviceMonitor.enabled=false \ + --set webhook.enabled=false \ + --set grafana.dashboards.namespace=yanet + + - name: Verify installation + run: | + kubectl get deployment -n yanet + kubectl get pods -n yanet + kubectl logs -n yanet -l app.kubernetes.io/name=yanet-operator --tail=50 || true + kubectl get crd yanets.yanet.yanet-platform.io + kubectl get crd yanetconfigs.yanet.yanet-platform.io + + - name: Check deployment status + run: | + kubectl rollout status deployment/yanet-operator -n yanet --timeout=2m + + - name: Debug on failure + if: failure() + run: | + echo "=== Deployment Status ===" + kubectl describe deployment -n yanet + echo "=== Pod Status ===" + kubectl describe pods -n yanet + echo "=== Events ===" + kubectl get events -n yanet --sort-by='.lastTimestamp' + + - name: Uninstall chart + if: always() + run: | + helm uninstall test -n yanet || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f827788 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,303 @@ +# Create GitHub Release with artifacts when a version tag is pushed +# Triggered on: version tags (v*) +# Artifacts: Docker images (Docker Hub, GHCR), Helm chart (OCI), install.yaml +name: Release + +on: + push: + tags: + - 'v*' # Trigger on version tags like v0.1.6 + +env: + DOCKERHUB_REPO: yanetplatform/yanet-operator + GHCR_REPO: ghcr.io/${{ github.repository }} + CHART_NAME: yanet-operator + +jobs: + # Build and push multi-platform Docker images + docker: + name: Build Docker Images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + version: ${{ steps.meta.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.DOCKERHUB_REPO }} + ${{ env.GHCR_REPO }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Build and push multi-platform images + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ steps.meta.outputs.version }} + + - name: Image digest + run: echo "${{ steps.meta.outputs.tags }}" + + # Package and push Helm chart to OCI registries + helm: + name: Package Helm Chart + runs-on: ubuntu-latest + needs: docker + permissions: + contents: read + packages: write + outputs: + chart_version: ${{ steps.chart_version.outputs.version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Log in to GitHub Container Registry + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin + + - name: Log in to Docker Hub + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | helm registry login registry-1.docker.io --username ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Extract chart version + id: chart_version + run: | + CHART_VERSION=$(grep '^version:' deploy/charts/yanet-operator/Chart.yaml | awk '{print $2}') + echo "version=${CHART_VERSION}" >> "$GITHUB_OUTPUT" + echo "Chart version: ${CHART_VERSION}" + + - name: Sync appVersion with git tag + run: | + GIT_TAG=${GITHUB_REF#refs/tags/v} + sed -i "s/^appVersion:.*/appVersion: \"${GIT_TAG}\"/" deploy/charts/yanet-operator/Chart.yaml + echo "Synced appVersion to ${GIT_TAG}" + + - name: Package Helm chart + run: | + helm package deploy/charts/yanet-operator + + - name: Push Helm chart to GitHub Container Registry + run: | + helm push ${{ env.CHART_NAME }}-${{ steps.chart_version.outputs.version }}.tgz oci://ghcr.io/${{ github.repository_owner }} + + - name: Push Helm chart to Docker Hub + run: | + helm push ${{ env.CHART_NAME }}-${{ steps.chart_version.outputs.version }}.tgz oci://registry-1.docker.io/yanetplatform + + - name: Upload chart artifact + uses: actions/upload-artifact@v4 + with: + name: helm-chart + path: ${{ env.CHART_NAME }}-*.tgz + retention-days: 90 + + # Generate install.yaml manifest + manifests: + name: Generate Manifests + runs-on: ubuntu-latest + needs: docker + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + cache: true + + - name: Install kustomize + run: | + make kustomize + + - name: Generate manifests + run: | + make manifests + + - name: Build install.yaml + env: + IMG: ${{ env.DOCKERHUB_REPO }}:${{ needs.docker.outputs.version }} + run: | + make build-installer + + - name: Upload install.yaml artifact + uses: actions/upload-artifact@v4 + with: + name: install-yaml + path: dist/install.yaml + retention-days: 90 + + # Create GitHub Release with artifacts and release notes + release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [docker, helm, manifests] + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for changelog generation + + - name: Download Helm chart artifact + uses: actions/download-artifact@v4 + with: + name: helm-chart + + - name: Download install.yaml artifact + uses: actions/download-artifact@v4 + with: + name: install-yaml + + - name: Generate release notes + id: release_notes + run: | + VERSION=${GITHUB_REF#refs/tags/} + + # Get previous tag + PREV_TAG=$(git describe --tags --abbrev=0 ${VERSION}^ 2>/dev/null || echo "") + + # Generate changelog + if [ -n "$PREV_TAG" ]; then + echo "## Changes since ${PREV_TAG}" > release_notes.md + echo "" >> release_notes.md + git log ${PREV_TAG}..${VERSION} --pretty=format:"- %s (%h)" --no-merges >> release_notes.md + else + echo "## Initial Release" > release_notes.md + fi + + # Add installation instructions + cat >> release_notes.md << 'EOF' + + ## πŸš€ Installation + + ### Quick Install (kubectl) + + ```bash + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.yaml + ``` + + ### Helm Install (Docker Hub OCI) + + ```bash + helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --version ${{ needs.helm.outputs.chart_version }} \ + --namespace yanet-system \ + --create-namespace + ``` + + ### Helm Install (GitHub Container Registry) + + ```bash + helm install yanet-operator \ + oci://ghcr.io/${{ github.repository_owner }}/yanet-operator \ + --version ${{ needs.helm.outputs.chart_version }} \ + --namespace yanet-system \ + --create-namespace + ``` + + ## πŸ“¦ Artifacts + + ### Docker Images + + **Multi-platform (amd64, arm64):** + - Docker Hub: `docker.io/${{ env.DOCKERHUB_REPO }}:${{ needs.docker.outputs.version }}` + - GHCR: `${{ env.GHCR_REPO }}:${{ needs.docker.outputs.version }}` + + ### Helm Chart + + **OCI Registries:** + - Docker Hub: `oci://registry-1.docker.io/yanetplatform/yanet-operator:${{ needs.helm.outputs.chart_version }}` + - GHCR: `oci://ghcr.io/${{ github.repository_owner }}/yanet-operator:${{ needs.helm.outputs.chart_version }}` + + ### Kubernetes Manifests + + - `install.yaml` β€” Complete installation manifest (CRDs + Operator) + + ## πŸ“š Documentation + + - [README](https://github.com/${{ github.repository }}/blob/${VERSION}/README.md) + - [Testing Guide](https://github.com/${{ github.repository }}/blob/${VERSION}/README_TESTS.md) + - [Validation Webhooks](https://github.com/${{ github.repository }}/blob/${VERSION}/README_WEBHOOKS.md) + - [Prometheus Metrics](https://github.com/${{ github.repository }}/blob/${VERSION}/README_METRICS.md) + - [Release Guide](https://github.com/${{ github.repository }}/blob/${VERSION}/README_RELEASES.md) + + ## βœ… Verification + + ```bash + # Verify Docker image + docker pull ${{ env.DOCKERHUB_REPO }}:${{ needs.docker.outputs.version }} + + # Verify Helm chart + helm show chart oci://registry-1.docker.io/yanetplatform/yanet-operator --version ${{ needs.helm.outputs.chart_version }} + + # Check operator version + kubectl get deployment yanet-operator -n yanet-system -o jsonpath='{.spec.template.spec.containers[0].image}' + ``` + EOF + + cat release_notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body_path: release_notes.md + files: | + ${{ env.CHART_NAME }}-*.tgz + install.yaml + draft: false + prerelease: false + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2922056 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +# Run tests on push and pull requests +# Ensures code quality and prevents regressions +name: Tests + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + cache: true + + - name: Run unit tests + run: make test-docker-unit + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: ./cover-unit.out + flags: unittests + name: unit-tests + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + cache: true + + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + - name: Run golangci-lint + run: | + golangci-lint run --timeout=5m + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + cache: true + + - name: Run integration tests + run: make test-docker + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: ./cover.out + flags: integration + name: integration-tests diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..7442676 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,83 @@ +# GolangCI-Lint configuration for yanet-operator + +run: + timeout: 5m + tests: true + skip-dirs: + - vendor + - config + - hack + - bin + +linters: + enable: + - errcheck # Check error handling + - gosimple # Simplify code + - govet # Go vet + - ineffassign # Detect ineffectual assignments + - staticcheck # Static analysis + - unused # Detect unused code + - gofmt # Check formatting + - goimports # Check imports + - misspell # Check spelling + - revive # Replacement for golint + - gosec # Security checks + - unconvert # Detect unnecessary conversions + - gocritic # Code critique + + disable: + - typecheck # Disabled due to potential issues with generated code + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + + govet: + check-shadowing: true + + revive: + rules: + - name: exported + disabled: true # Disabled for generated code + + gosec: + excludes: + - G404 # Weak random number generator (not critical for operator) + + gocritic: + enabled-tags: + - diagnostic + - performance + - style + disabled-checks: + - commentedOutCode + - ifElseChain + - hugeParam + - rangeValCopy + +issues: + exclude-rules: + # Exclude generated files + - path: zz_generated\.deepcopy\.go + linters: + - all + + # Exclude test files from some checks + - path: _test\.go + linters: + - errcheck + - gosec + + # Exclude suite_test.go (setup file) + - path: suite_test\.go + linters: + - all + + max-issues-per-linter: 0 + max-same-issues: 0 + +output: + format: colored-line-number + print-issued-lines: true + print-linter-name: true diff --git a/FOR_AI.md b/FOR_AI.md new file mode 100644 index 0000000..67f1f39 --- /dev/null +++ b/FOR_AI.md @@ -0,0 +1,318 @@ +# FOR AI: yanet-operator Development Guide + +This document provides context and guidelines for AI assistants working on this project. + +## 🎯 Project Overview + +**yanet-operator** is a Kubernetes operator that manages YANET (Yet Another Network) deployments on worker nodes. + +- **Language:** Go 1.26.2 +- **Framework:** controller-runtime (Kubernetes operator framework) +- **Repository:** https://github.com/yanet-platform/yanet-operator +- **CRDs:** Yanet (per-node), YanetConfig (global) +- **Test Coverage:** 87.6% (manifests: 97.4%, helpers: 65.7%) +- **Status:** Production-ready with comprehensive features + +## πŸ“‹ Critical Rules + +### 1. Code Comments +- βœ… **ALL comments MUST be in English only** +- βœ… Use generic examples in tests: `test-node`, `docker.io/test`, etc. +- ❌ NO references to internal hostnames or registries in tests +- βœ… **Use structured logging** β€” key-value pairs, not fmt.Sprintf + +### 2. Testing Requirements +- βœ… **ALL new code MUST have tests** +- βœ… **Target coverage: 70%+** (current: 87.6%) +- βœ… **Run tests through Docker** (no local dependencies) +- βœ… **Use race detector** for concurrency testing + +### 3. Test Workflow +When adding new functionality: +1. Write tests FIRST (TDD approach) +2. Add test to appropriate `*_test.go` file +3. Update `Makefile` if needed (new test targets) +4. Ensure GitHub Actions workflow covers it +5. Run `make test-docker-unit` to verify +6. Check coverage: `go tool cover -func=cover.out` + +### 4. Concurrency Rules +- βœ… **Use `DeepCopy()` for shared state** +- βœ… **Always hold mutex when accessing shared data** +- ❌ **NEVER save references to data protected by mutex** +- βœ… **Run `make test-docker-race` to detect data races** + +## πŸ—οΈ Project Structure + +``` +yanet-operator/ +β”œβ”€β”€ api/v1alpha1/ # CRD definitions +β”‚ β”œβ”€β”€ yanet_types.go # Yanet CRD (per-node) +β”‚ β”œβ”€β”€ yanetconfig_types.go # YanetConfig CRD (global) +β”‚ └── zz_generated.deepcopy.go # Generated (DO NOT EDIT) +β”œβ”€β”€ cmd/ +β”‚ └── main.go # Entry point +β”œβ”€β”€ internal/ +β”‚ β”œβ”€β”€ controller/ # Reconcilers +β”‚ β”‚ β”œβ”€β”€ yanet_controller.go +β”‚ β”‚ β”œβ”€β”€ yanet_reconciler.go +β”‚ β”‚ β”œβ”€β”€ yanetconfig_controller.go +β”‚ β”‚ β”œβ”€β”€ node_reconciler.go +β”‚ β”‚ β”œβ”€β”€ suite_test.go # Test setup +β”‚ β”‚ └── *_test.go # Integration tests +β”‚ β”œβ”€β”€ helpers/ # Utilities +β”‚ β”‚ β”œβ”€β”€ helpers.go +β”‚ β”‚ β”œβ”€β”€ http_getters.go +β”‚ β”‚ └── *_test.go # Unit tests +β”‚ β”œβ”€β”€ manifests/ # Deployment generators +β”‚ β”‚ β”œβ”€β”€ dataplane.go +β”‚ β”‚ β”œβ”€β”€ controlplane.go +β”‚ β”‚ β”œβ”€β”€ announcer.go +β”‚ β”‚ β”œβ”€β”€ bird.go +β”‚ β”‚ β”œβ”€β”€ helpers.go +β”‚ β”‚ └── *_test.go # Unit tests (97.4% coverage!) +β”‚ └── names/ # Constants +β”‚ └── const.go +β”œβ”€β”€ .github/workflows/ # CI/CD +β”‚ └── test.yml # GitHub Actions +β”œβ”€β”€ Makefile # Build and test targets +β”œβ”€β”€ .golangci.yml # Linter config +└── README_TESTS.md # Testing documentation +``` + +## πŸ§ͺ Testing Guidelines + +### Test File Naming +- Unit tests: `_test.go` in same directory +- Integration tests: `_integration_test.go` +- Test package: `package ` (same as source) or `package _test` (black-box) + +### Test Structure + +**Unit test (table-driven):** +```go +func TestMyFunction(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "description", + input: "input", + expected: "expected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MyFunction(tt.input) + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} +``` + +**Integration test (Ginkgo/Gomega):** +```go +var _ = Describe("MyController", func() { + Context("When doing something", func() { + It("Should work correctly", func() { + obj := &MyObject{} + Expect(k8sClient.Create(ctx, obj)).Should(Succeed()) + }) + }) +}) +``` + +### Running Tests + +**Docker-based (recommended):** +```bash +make test-docker-unit # Unit tests only +make test-docker-race # With race detector +``` + +**Local (requires Go 1.26.2 and envtest):** +```bash +make test-unit # Unit tests +make test-integration # Integration tests +make test # All tests +make test-race # With race detector +``` + +### Adding New Tests + +1. Create `*_test.go` file in same directory as source +2. Write table-driven tests +3. Run locally: `make test-docker-unit` +4. Check coverage: `go tool cover -func=cover.out` +5. Ensure coverage doesn't decrease + +## πŸ”§ Makefile Targets + +### Testing +- `make test` β€” all tests with coverage +- `make test-race` β€” with race detector +- `make test-unit` β€” unit tests only +- `make test-integration` β€” integration tests only +- `make test-docker-unit` β€” unit tests in Docker +- `make test-docker-race` β€” race detector in Docker + +### Development +- `make fmt` β€” format code +- `make vet` β€” run go vet +- `make lint` β€” run golangci-lint +- `make build` β€” build binary +- `make generate` β€” generate DeepCopy methods +- `make manifests` β€” generate CRDs + +## πŸ€– GitHub Actions + +### Workflow: `.github/workflows/test.yml` + +**Triggers:** +- Push to: `main`, `master`, `develop` +- Pull requests to these branches + +**Jobs:** +1. **test** β€” unit + integration tests with race detector +2. **lint** β€” golangci-lint +3. **build** β€” build operator binary + +**Important:** +- Go version from `go.mod` (via `go-version-file`) +- Synchronized with `Dockerfile` +- Codecov integration for coverage tracking + +### Adding New Workflow Steps + +When adding new test targets to Makefile: +1. Add corresponding step in `.github/workflows/test.yml` +2. Ensure it uses Docker or has all dependencies +3. Test locally first + +## πŸ› Common Issues + +### Data Races +**Problem:** Shared state accessed without mutex +**Solution:** +```go +// ❌ BAD +r.GlobalConfig.Config = config.Spec + +// βœ… GOOD +r.GlobalConfig.Lock.Lock() +r.GlobalConfig.Config = *config.Spec.DeepCopy() +r.GlobalConfig.Lock.Unlock() +``` + +### Test Failures in Docker +**Problem:** Integration tests fail with "etcd not found" +**Solution:** Integration tests require envtest, use `make test-integration` locally + +### Coverage Decrease +**Problem:** New code without tests +**Solution:** Write tests before committing, run `make test-docker-unit` + +## πŸ“ Code Review Checklist + +Before submitting PR: +- [ ] All comments in English +- [ ] Tests added for new code +- [ ] `make test-docker-unit` passes +- [ ] `make test-docker-race` passes (no data races) +- [ ] `make lint` passes +- [ ] `make fmt` applied +- [ ] Coverage >= 70% (check with `go tool cover`) +- [ ] No references to internal hostnames/registries in tests +- [ ] GitHub Actions workflow updated if needed + +## 🎯 Architecture Patterns + +### Shared State +```go +type MutexYanetConfigSpec struct { + Config YanetConfigSpec + Lock sync.Mutex +} + +// Always use DeepCopy when reading/writing +r.GlobalConfig.Lock.Lock() +config := *r.GlobalConfig.Config.DeepCopy() +r.GlobalConfig.Lock.Unlock() +``` + +### Deployment Generation +- Factory functions: `DeploymentForDataplane()`, `DeploymentForControlplane()`, etc. +- Helpers in `internal/manifests/helpers.go` +- All functions have 97.4% test coverage + +### Controller Pattern +- `YanetReconciler` β€” manages Yanet resources and Nodes +- `YanetConfigReconciler` β€” manages global configuration +- Both share `GlobalConfig` via pointer + +## πŸ” Known Limitations + +### Acceptable (by design) +- βœ… UpdateWindow state in memory (not persistent) +- βœ… AutoDiscovery without retry (not priority) +- βœ… No caching for AutoDiscovery (not priority) + +### To Be Implemented (future) +- [ ] Validation webhooks +- [ ] Finalizers for graceful cleanup +- [ ] Metrics for monitoring +- [ ] Events for auditing +- [ ] Status conditions + +## πŸ“š Resources + +- [Controller Runtime](https://github.com/kubernetes-sigs/controller-runtime) +- [Kubebuilder Book](https://book.kubebuilder.io/) +- [Ginkgo Testing Framework](https://onsi.github.io/ginkgo/) +- [Gomega Matchers](https://onsi.github.io/gomega/) +- [golangci-lint](https://golangci-lint.run/) + +## 🚨 Critical Files (DO NOT EDIT) + +- `api/v1alpha1/zz_generated.deepcopy.go` β€” auto-generated +- `config/crd/bases/*.yaml` β€” generated by controller-gen +- Run `make generate` and `make manifests` to regenerate + +## πŸ’‘ Tips for AI Assistants + +1. **Always check existing tests** before writing new ones +2. **Follow table-driven test pattern** for consistency +3. **Use Docker for testing** to avoid environment issues +4. **Check coverage** after adding tests +5. **Update documentation** when adding new features +6. **Keep comments in English** β€” this is non-negotiable +7. **Test with race detector** β€” data races are critical bugs +8. **Reference line numbers** when discussing code issues + +## πŸŽ“ Learning from This Project + +### Good Practices Implemented +- βœ… Comprehensive test suite (87.6% coverage) +- βœ… Docker-based testing (reproducible) +- βœ… GitHub Actions CI/CD +- βœ… Table-driven tests +- βœ… Race detector in CI +- βœ… Clear separation of concerns + +### Lessons Learned +- Data races are subtle β€” always use DeepCopy for shared state +- Docker-based tests eliminate "works on my machine" issues +- High test coverage (97.4% for manifests) catches bugs early +- Integration tests need envtest setup +- Comments in English improve collaboration + +--- + +**Last Updated:** 2026-04-29 +**Test Coverage:** 87.6% +**Status:** Production-ready with comprehensive test suite diff --git a/Makefile b/Makefile index 9d6d2b7..89f20c8 100644 --- a/Makefile +++ b/Makefile @@ -1,74 +1,48 @@ # VERSION defines the project version for the bundle. -# Update this value when you upgrade the version of your project. -# To re-generate a bundle for another specific version without changing the standard setup, you can: -# - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) -# - use environment variables to overwrite this value (e.g export VERSION=0.0.2) VERSION ?= 0.0.1 # CHANNELS define the bundle channels used in the bundle. -# Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") -# To re-generate a bundle for other specific channels without changing the standard setup, you can: -# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable) -# - use environment variables to overwrite this value (e.g export CHANNELS="candidate,fast,stable") ifneq ($(origin CHANNELS), undefined) BUNDLE_CHANNELS := --channels=$(CHANNELS) endif # DEFAULT_CHANNEL defines the default channel used in the bundle. -# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable") -# To re-generate a bundle for any other default channel without changing the default setup, you can: -# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable) -# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable") ifneq ($(origin DEFAULT_CHANNEL), undefined) BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) endif BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) # IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images. -# This variable is used to construct full image tags for bundle and catalog images. -# -# For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both -# yanet-platform.io/yanet-operator-bundle:$VERSION and yanet-platform.io/yanet-operator-catalog:$VERSION. IMAGE_TAG_BASE ?= yanet-platform.io/yanet-operator # BUNDLE_IMG defines the image:tag used for the bundle. -# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION) # BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command BUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) # USE_IMAGE_DIGESTS defines if images are resolved via tags or digests -# You can enable this value if you would like to use SHA Based Digests -# To enable set flag to true USE_IMAGE_DIGESTS ?= false ifeq ($(USE_IMAGE_DIGESTS), true) BUNDLE_GEN_FLAGS += --use-image-digests endif -# Set the Operator SDK version to use. By default, what is installed on the system is used. -# This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. +# Set the Operator SDK version to use. OPERATOR_SDK_VERSION ?= v1.36.1 + # Image URL to use all building/pushing image targets IMG ?= controller:latest + # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.35.0 -# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) -ifeq (,$(shell go env GOBIN)) -GOBIN=$(shell go env GOPATH)/bin -else -GOBIN=$(shell go env GOBIN) -endif - # CONTAINER_TOOL defines the container tool to be used for building images. -# Be aware that the target commands are only tested with Docker which is -# scaffolded by default. However, you might want to replace it to use other -# tools. (i.e. podman) CONTAINER_TOOL ?= docker +# GO_VERSION defines the Go version used in Docker containers +GO_VERSION ?= 1.26.2 + # Setting SHELL to bash allows bash commands to be executed by recipes. -# Options are set to exit when a recipe line exits non-zero or a piped command fails. SHELL = /usr/bin/env bash -o pipefail .SHELLFLAGS = -ec @@ -77,69 +51,96 @@ all: build ##@ General -# The help target prints out all targets with their descriptions organized -# beneath their categories. The categories are represented by '##@' and the -# target descriptions by '##'. The awk command is responsible for reading the -# entire set of makefiles included in this invocation, looking for lines of the -# file as xyz: ## something, and then pretty-format the target and help. Then, -# if there's a line with ##@ something, that gets pretty-printed as a category. -# More info on the usage of ANSI control characters for terminal formatting: -# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters -# More info on the awk command: -# http://linuxcommand.org/lc3_adv_awk.php - .PHONY: help help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) ##@ Development +# docker-go runs a Go command in a Docker container +# Usage: $(call docker-go,command) +define docker-go + $(CONTAINER_TOOL) run --rm \ + -v $(shell pwd):/workspace \ + -w /workspace \ + golang:$(GO_VERSION) \ + /bin/bash -c "$(1)" +endef + .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. - $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + $(call docker-go,./bin/controller-gen-$(CONTROLLER_TOOLS_VERSION) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases) .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. - $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + $(call docker-go,./bin/controller-gen-$(CONTROLLER_TOOLS_VERSION) object:headerFile="hack/boilerplate.go.txt" paths="./...") + +.PHONY: helm-crds +helm-crds: manifests kustomize ## Build CRDs for Helm chart. + $(KUSTOMIZE) build config/crd > deploy/charts/yanet-operator/crds/yanet.yaml .PHONY: fmt fmt: ## Run go fmt against code. - go fmt ./... + $(call docker-go,go fmt ./...) .PHONY: vet vet: ## Run go vet against code. - go vet ./... + $(call docker-go,go vet ./...) .PHONY: test -test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out +test: test-docker ## Run all tests in Docker (recommended). + +.PHONY: test-race +test-race: test-docker-race ## Run tests with race detector in Docker. + +.PHONY: test-unit +test-unit: test-docker-unit ## Run unit tests only (no integration tests) in Docker. + +.PHONY: test-integration +test-integration: test-docker-integration ## Run integration tests only in Docker. + +.PHONY: test-docker +test-docker: helm-crds ## Run all tests in Docker container. + $(call docker-go,go mod download && \ + go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest && \ + KUBEBUILDER_ASSETS=\$$(/go/bin/setup-envtest use $(ENVTEST_K8S_VERSION) -p path) go test ./... -coverprofile cover.out) -# Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. -.PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. -test-e2e: - go test ./test/e2e/ -v -ginkgo.v +.PHONY: test-docker-race +test-docker-race: ## Run tests with race detector in Docker container. + $(call docker-go,go mod download && \ + go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest && \ + KUBEBUILDER_ASSETS=\$$(/go/bin/setup-envtest use $(ENVTEST_K8S_VERSION) -p path) go test -race ./... -coverprofile cover.out) + +.PHONY: test-docker-unit +test-docker-unit: ## Run unit tests in Docker container. + $(call docker-go,go mod download && \ + go test -v ./internal/helpers/... ./internal/manifests/... -coverprofile cover-unit.out) + +.PHONY: test-docker-integration +test-docker-integration: ## Run integration tests in Docker container. + $(call docker-go,go mod download && \ + go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest && \ + KUBEBUILDER_ASSETS=\$$(/go/bin/setup-envtest use $(ENVTEST_K8S_VERSION) -p path) go test -v ./internal/controller/... -coverprofile cover-integration.out) .PHONY: lint -lint: golangci-lint ## Run golangci-lint linter & yamllint +lint: golangci-lint ## Run golangci-lint linter. $(GOLANGCI_LINT) run .PHONY: lint-fix -lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes +lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes. $(GOLANGCI_LINT) run --fix +.PHONY: lint-docker +lint-docker: ## Run golangci-lint linter in Docker container. + $(call docker-go,go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest && \ + GOFLAGS=-buildvcs=false /go/bin/golangci-lint run --timeout=5m) + ##@ Build .PHONY: build -build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go - -.PHONY: run -run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/main.go +build: manifests generate fmt vet ## Build manager binary in Docker. + $(call docker-go,go build -o bin/manager cmd/main.go) -# If you wish to build the manager image targeting other platforms you can use the --platform flag. -# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. -# More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. $(CONTAINER_TOOL) build -t ${IMG} . @@ -148,16 +149,10 @@ docker-build: ## Build docker image with the manager. docker-push: ## Push docker image with the manager. $(CONTAINER_TOOL) push ${IMG} -# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple -# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: -# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ -# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ -# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) -# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. -PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +# PLATFORMS defines the target platforms for multi-arch builds +PLATFORMS ?= linux/arm64,linux/amd64 .PHONY: docker-buildx -docker-buildx: ## Build and push docker image for the manager for cross-platform support - # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile +docker-buildx: ## Build and push docker image for cross-platform support. sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name project-v3-builder $(CONTAINER_TOOL) buildx use project-v3-builder @@ -182,7 +177,7 @@ install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~ $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - .PHONY: uninstall -uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. +uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy @@ -191,7 +186,7 @@ deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - .PHONY: undeploy -undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. +undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies @@ -274,7 +269,7 @@ bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metada .PHONY: bundle-build bundle-build: ## Build the bundle image. - docker build -f bundle.Dockerfile -t $(BUNDLE_IMG) . + $(CONTAINER_TOOL) build -f bundle.Dockerfile -t $(BUNDLE_IMG) . .PHONY: bundle-push bundle-push: ## Push the bundle image. @@ -297,11 +292,10 @@ OPM = $(shell which opm) endif endif -# A comma-separated list of bundle images (e.g. make catalog-build BUNDLE_IMGS=example.com/operator-bundle:v0.1.0,example.com/operator-bundle:v0.2.0). -# These images MUST exist in a registry and be pull-able. +# A comma-separated list of bundle images BUNDLE_IMGS ?= $(BUNDLE_IMG) -# The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0). +# The image tag given to the resulting catalog image CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION) # Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image. @@ -309,14 +303,10 @@ ifneq ($(origin CATALOG_BASE_IMG), undefined) FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG) endif -# Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'. -# This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see: -# https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator .PHONY: catalog-build catalog-build: opm ## Build a catalog image. $(OPM) index add --container-tool docker --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT) -# Push the catalog image. .PHONY: catalog-push catalog-push: ## Push a catalog image. $(MAKE) docker-push IMG=$(CATALOG_IMG) diff --git a/README.md b/README.md index 885fe43..5f4f0d0 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,182 @@ # yanet-operator -Yanet Operator: -- monitoring new and changes of existing yanet/yanet-config crds. -- create Deployment based on data from yanet crd spec(only one deployment per worker node). -- update Deployment on yanet crd changes. -- delete Deployment when yanet crd removed. -## Description -Yanet Operator +[![Docker Hub](https://img.shields.io/docker/v/yanetplatform/yanet-operator?label=Docker%20Hub&logo=docker)](https://hub.docker.com/r/yanetplatform/yanet-operator) +[![GitHub Container Registry](https://img.shields.io/badge/GHCR-latest-blue?logo=github)](https://github.com/yanet-platform/yanet-operator/pkgs/container/yanet-operator) +[![Helm Chart](https://img.shields.io/badge/Helm-OCI-0f1689?logo=helm)](https://hub.docker.com/r/yanetplatform/yanet-operator) -## Getting Started -You’ll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for testing, or run against a remote cluster. -**Note:** Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster `kubectl cluster-info` shows). +Kubernetes operator for managing YANET (Yet Another Network) deployments on worker nodes. -### Running on the cluster -1. Install Instances of Custom Resources: +## Features -```sh -kubectl apply -f config/samples/ +- πŸ”„ **Automated Deployment Management** β€” Creates and updates Deployments based on Yanet CRD specs +- 🎯 **Per-Node Deployment** β€” One deployment per worker node with node affinity +- πŸ” **Auto-Discovery** β€” Automatically discovers and manages Yanet instances +- πŸ“Š **Status Conditions** β€” Ready, Synced, and Progressing conditions +- 🧹 **Graceful Cleanup** β€” Finalizers ensure proper resource cleanup +- πŸ“ **Kubernetes Events** β€” Audit trail for all operator actions +- βœ… **Validation Webhooks** β€” Admission control for Yanet and YanetConfig resources +- πŸ“ˆ **Prometheus Metrics** β€” Reconciliation metrics and resource monitoring +- πŸ§ͺ **93.2% Test Coverage** β€” Comprehensive unit and integration tests +- πŸš€ **CI/CD Ready** β€” GitHub Actions for testing and publishing + +## Quick Start + +### Installation via Helm (Recommended) + +```bash +# Install from Docker Hub OCI registry +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --version 0.1.5 \ + --namespace yanet-system \ + --create-namespace + +# Or install from GitHub Container Registry +helm install yanet-operator \ + oci://ghcr.io/yanet-platform/yanet-operator \ + --version 0.1.5 \ + --namespace yanet-system \ + --create-namespace ``` -2. Build and push your image to the location specified by `IMG`: +### Installation via kubectl -```sh -make docker-build docker-push IMG=/yanet-operator:tag +```bash +# Apply CRDs and operator deployment +kubectl apply -f https://github.com/yanet-platform/yanet-operator/releases/latest/download/install.yaml ``` -3. Deploy the controller to the cluster with the image specified by `IMG`: +## Usage -```sh -make deploy IMG=/yanet-operator:tag +### Create a Yanet instance + +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: Yanet +metadata: + name: yanet-worker-01 +spec: + nodeName: worker-01 + type: release + dataplane: + enable: true + image: yanetplatform/yanet-dataplane + tag: latest + controlplane: + enable: true + image: yanetplatform/yanet-controlplane + tag: latest ``` -### Update helm chart -1. Build image: -```sh -make docker-build +```bash +kubectl apply -f yanet-instance.yaml ``` -2. tag image and push it. -3. Update crds(if need): -```sh -make manifests; ./bin/kustomize build config/crd > deploy/charts/yanet-operator/crds/yanet.yaml + +### Configure global settings + +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: YanetConfig +metadata: + name: yanet-config +spec: + updateWindow: 300 + autoDiscovery: + enable: true + namespace: default ``` -4. Update rbac roles(if need): -```sh -make manifests; ./bin/kustomize build config/rbac/ | sed 's/system/{{ .Values.namespace }}/g' > deploy/charts/yanet-operator/templates/rbac.yaml + +## Documentation + +- πŸ“– [Testing Guide](README_TESTS.md) β€” How to run tests and contribute +- βœ… [Validation Webhooks](README_WEBHOOKS.md) β€” Admission control and validation rules +- πŸ“ˆ [Prometheus Metrics](README_METRICS.md) β€” Monitoring and observability +- πŸš€ [Release Guide](README_RELEASES.md) β€” How to create and publish releases +- πŸ—οΈ [Architecture Analysis](ARCHITECTURE_ANALYSIS.md) β€” Design decisions and roadmap +- πŸ€– [AI Development Guide](FOR_AI.md) β€” Guidelines for AI assistants + +## Development + +### Prerequisites + +- Go 1.26.2+ +- Docker +- kubectl +- Helm 3+ +- Kind (for local testing) + +## Getting Started + +You'll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for testing, or run against a remote cluster. + +**Note:** Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster `kubectl cluster-info` shows). + +### Build and Run Locally + +```bash +# Run tests +make test + +# Run tests with race detector +make test-race + +# Run tests in Docker (no local Go required) +make test-docker + +# Build binary +make build + +# Run locally (against current kubeconfig context) +make run +``` + +### Build and Push Docker Image + +```bash +# Build image +make docker-build IMG=yanetplatform/yanet-operator:v0.1.5 + +# Push to Docker Hub +make docker-push IMG=yanetplatform/yanet-operator:v0.1.5 +``` + +### Deploy to Cluster + +```bash +# Install CRDs +make install + +# Deploy operator +make deploy IMG=yanetplatform/yanet-operator:v0.1.5 + +# Create sample resources +kubectl apply -f config/samples/ +``` + +### Update Helm Chart + +```bash +# 1. Update CRDs (if API changed) +make manifests +./bin/kustomize build config/crd > deploy/charts/yanet-operator/crds/yanet.yaml + +# 2. Update RBAC (if permissions changed) +make manifests +./bin/kustomize build config/rbac/ | sed 's/system/{{ .Values.namespace }}/g' > deploy/charts/yanet-operator/templates/rbac.yaml + +# 3. Update version in Chart.yaml +# Edit deploy/charts/yanet-operator/Chart.yaml + +# 4. Test chart locally +helm lint deploy/charts/yanet-operator +helm template test deploy/charts/yanet-operator + +# 5. Create git tag to trigger publishing +git tag v0.1.5 +git push origin v0.1.5 ``` -4. Update version in helm chart. + +**Note:** GitHub Actions will automatically build and publish Docker images and Helm charts when you push a version tag. ### Uninstall CRDs To delete the CRDs from the cluster: @@ -89,9 +220,27 @@ If you are editing the API definitions, generate the manifests such as CRs or CR make manifests ``` -**NOTE:** Run `make --help` for more information on all potential `make` targets +**NOTE:** Run `make help` for more information on all potential `make` targets + +## CI/CD + +The project uses GitHub Actions for automated testing and publishing: + +- **Tests** β€” Run on every push and PR +- **Docker Images** β€” Published to Docker Hub and GHCR on version tags +- **Helm Charts** β€” Published to OCI registries on version tags + +See [GITHUB_ACTIONS_DOCKER.md](GITHUB_ACTIONS_DOCKER.md) for setup instructions. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. + +## Resources -More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) +- [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) +- [YANET Platform](https://github.com/yanet-platform) +- [Docker Hub Repository](https://hub.docker.com/r/yanetplatform/yanet-operator) ## License diff --git a/README_METRICS.md b/README_METRICS.md new file mode 100644 index 0000000..0ab72fa --- /dev/null +++ b/README_METRICS.md @@ -0,0 +1,370 @@ +# Prometheus Metrics for yanet-operator + +## Overview + +yanet-operator exposes Prometheus metrics for monitoring reconciliation performance and resource state. + +## Available Metrics + +### Yanet Controller Metrics + +#### `yanet_reconcile_total` +**Type:** Counter +**Labels:** `name`, `namespace`, `result` +**Description:** Total number of reconciliations per Yanet resource + +**Example:** +```promql +# Total reconciliations for yanet-node1 +yanet_reconcile_total{name="yanet-node1",namespace="default",result="success"} + +# Error rate +rate(yanet_reconcile_total{result="error"}[5m]) +``` + +#### `yanet_reconcile_duration_seconds` +**Type:** Histogram +**Labels:** `name`, `namespace` +**Description:** Duration of Yanet reconciliations in seconds + +**Example:** +```promql +# 95th percentile reconciliation time +histogram_quantile(0.95, rate(yanet_reconcile_duration_seconds_bucket[5m])) + +# Average reconciliation time +rate(yanet_reconcile_duration_seconds_sum[5m]) / rate(yanet_reconcile_duration_seconds_count[5m]) +``` + +#### `yanet_deployments_out_of_sync` +**Type:** Gauge +**Labels:** `name`, `namespace` +**Description:** Number of deployments that are out of sync per Yanet resource + +**Example:** +```promql +# Resources with out-of-sync deployments +yanet_deployments_out_of_sync > 0 + +# Total out-of-sync deployments across all resources +sum(yanet_deployments_out_of_sync) +``` + +### YanetConfig Controller Metrics + +#### `yanetconfig_reconcile_total` +**Type:** Counter +**Labels:** `name`, `namespace`, `result` +**Description:** Total number of reconciliations per YanetConfig resource + +**Example:** +```promql +# Total config reconciliations +yanetconfig_reconcile_total{name="global-config",namespace="default"} +``` + +#### `yanetconfig_reconcile_duration_seconds` +**Type:** Histogram +**Labels:** `name`, `namespace` +**Description:** Duration of YanetConfig reconciliations in seconds + +**Example:** +```promql +# Config reconciliation latency +histogram_quantile(0.99, rate(yanetconfig_reconcile_duration_seconds_bucket[5m])) +``` + +### Resource Metrics + +#### `yanet_resources_total` +**Type:** Gauge +**Labels:** `type` +**Description:** Total number of Yanet resources + +**Example:** +```promql +# Total Yanet resources by type +yanet_resources_total{type="release"} +yanet_resources_total{type="balancer"} +``` + +#### `yanet_resources_ready` +**Type:** Gauge +**Labels:** `type` +**Description:** Number of ready Yanet resources + +**Example:** +```promql +# Ready vs total resources +yanet_resources_ready / yanet_resources_total +``` + +## Installation + +### Enable Metrics in Helm Chart + +Metrics are enabled by default: + +```yaml +# values.yaml +metrics: + enabled: true + serviceMonitor: + enabled: true + interval: 30s + release: kube-prometheus-stack # Must match your Prometheus Operator release +``` + +### Disable Metrics + +To disable metrics collection: + +```yaml +metrics: + enabled: false +``` + +### Custom Prometheus Release + +If your Prometheus Operator uses a different release name: + +```yaml +metrics: + serviceMonitor: + release: my-prometheus-release +``` + +## ServiceMonitor + +When metrics are enabled, a ServiceMonitor resource is created for Prometheus Operator: + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: yanet-operator + labels: + release: kube-prometheus-stack +spec: + endpoints: + - port: metrics + interval: 30s + selector: + matchLabels: + app.kubernetes.io/name: yanet-operator +``` + +## Grafana Dashboard + +### Installation + +The Helm chart includes a pre-built Grafana dashboard that can be automatically deployed as a ConfigMap: + +```yaml +# values.yaml +grafana: + dashboards: + enabled: true + namespace: kube-mon # Namespace where Grafana is installed + labels: + grafana_dashboard: "1" # Label used by Grafana sidecar for discovery +``` + +The dashboard will be automatically discovered by Grafana if you're using the [Grafana sidecar](https://github.com/grafana/helm-charts/tree/main/charts/grafana#sidecar-for-dashboards) with the following configuration: + +```yaml +# Grafana Helm values +sidecar: + dashboards: + enabled: true + label: grafana_dashboard +``` + +### Dashboard Features + +The included dashboard provides: + +- **Reconciliation Rate**: Success and error rates for Yanet and YanetConfig resources +- **Reconciliation Latency**: P50, P95, P99 percentiles for reconciliation duration +- **Deployments Out of Sync**: Gauge showing resources with synchronization issues +- **Resource Metrics**: Total and ready resources by type +- **Success Rate**: Overall success rate trends + +### Manual Installation + +If you prefer to import the dashboard manually: + +1. Get the dashboard JSON: +```bash +kubectl get configmap yanet-operator-dashboard -n kube-mon -o jsonpath='{.data.yanet-operator\.json}' > dashboard.json +``` + +2. Import in Grafana UI: **Dashboards β†’ Import β†’ Upload JSON file** + +### Custom Queries + +You can create custom panels using these example queries: + +#### Reconciliation Performance + +```promql +# Reconciliation rate +rate(yanet_reconcile_total[5m]) + +# Error rate +rate(yanet_reconcile_total{result="error"}[5m]) + +# Success rate +rate(yanet_reconcile_total{result="success"}[5m]) / rate(yanet_reconcile_total[5m]) +``` + +#### Latency Monitoring + +```promql +# P50 latency +histogram_quantile(0.50, rate(yanet_reconcile_duration_seconds_bucket[5m])) + +# P95 latency +histogram_quantile(0.95, rate(yanet_reconcile_duration_seconds_bucket[5m])) + +# P99 latency +histogram_quantile(0.99, rate(yanet_reconcile_duration_seconds_bucket[5m])) +``` + +#### Resource Health + +```promql +# Out-of-sync deployments +sum(yanet_deployments_out_of_sync) by (name, namespace) + +# Resources with issues +count(yanet_deployments_out_of_sync > 0) +``` + +## Alerting Rules + +### Example PrometheusRule + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: yanet-operator-alerts +spec: + groups: + - name: yanet-operator + interval: 30s + rules: + - alert: YanetHighErrorRate + expr: | + rate(yanet_reconcile_total{result="error"}[5m]) > 0.1 + for: 5m + labels: + severity: warning + annotations: + summary: "High error rate in Yanet reconciliation" + description: "Yanet {{ $labels.name }} in {{ $labels.namespace }} has error rate {{ $value }}" + + - alert: YanetDeploymentsOutOfSync + expr: | + yanet_deployments_out_of_sync > 0 + for: 10m + labels: + severity: warning + annotations: + summary: "Yanet deployments out of sync" + description: "Yanet {{ $labels.name }} has {{ $value }} deployments out of sync" + + - alert: YanetHighReconciliationLatency + expr: | + histogram_quantile(0.95, rate(yanet_reconcile_duration_seconds_bucket[5m])) > 30 + for: 5m + labels: + severity: warning + annotations: + summary: "High reconciliation latency" + description: "P95 reconciliation latency is {{ $value }}s" +``` + +## Troubleshooting + +### Metrics not appearing in Prometheus + +1. Check that ServiceMonitor is created: +```bash +kubectl get servicemonitor -n yanet-operator +``` + +2. Check that the `release` label matches your Prometheus Operator: +```bash +kubectl get servicemonitor yanet-operator -n yanet-operator -o yaml | grep release +``` + +3. Check Prometheus Operator logs: +```bash +kubectl logs -n monitoring -l app.kubernetes.io/name=prometheus-operator +``` + +4. Verify metrics endpoint is accessible: +```bash +kubectl port-forward -n yanet-operator svc/yanet-operator-metrics 8080:8080 +curl http://localhost:8080/metrics +``` + +### ServiceMonitor not picked up by Prometheus + +Ensure your Prometheus Operator is configured to watch the namespace: + +```yaml +# Prometheus CR +spec: + serviceMonitorNamespaceSelector: {} # Watch all namespaces + # OR + serviceMonitorNamespaceSelector: + matchLabels: + monitoring: enabled +``` + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Yanet Controller β”‚ +β”‚ β”‚ +β”‚ - Reconcile │──┐ +β”‚ - Update Status β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ Record Metrics +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ YanetConfig β”‚ β”‚ +β”‚ Controller │─── +β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Metrics β”‚ + β”‚ Registry β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ HTTP /metrics + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Prometheus β”‚ + β”‚ Scraper β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Grafana β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Files + +- [`internal/controller/metrics.go`](internal/controller/metrics.go) β€” Metrics definitions +- [`internal/controller/yanet_controller.go`](internal/controller/yanet_controller.go) β€” Yanet metrics recording +- [`internal/controller/yanetconfig_controller.go`](internal/controller/yanetconfig_controller.go) β€” YanetConfig metrics recording +- [`deploy/charts/yanet-operator/templates/metrics-service.yaml`](deploy/charts/yanet-operator/templates/metrics-service.yaml) β€” Metrics Service +- [`deploy/charts/yanet-operator/templates/servicemonitor.yaml`](deploy/charts/yanet-operator/templates/servicemonitor.yaml) β€” ServiceMonitor for Prometheus Operator +- [`deploy/charts/yanet-operator/templates/grafana-dashboard.yaml`](deploy/charts/yanet-operator/templates/grafana-dashboard.yaml) β€” Grafana Dashboard ConfigMap +- [`deploy/charts/yanet-operator/dashboards/yanet-operator.json`](deploy/charts/yanet-operator/dashboards/yanet-operator.json) β€” Grafana Dashboard JSON diff --git a/README_RELEASES.md b/README_RELEASES.md new file mode 100644 index 0000000..05aa9b6 --- /dev/null +++ b/README_RELEASES.md @@ -0,0 +1,360 @@ +# Release Guide + +This document describes the release process for yanet-operator. + +## 🎯 Overview + +Releases are fully automated via GitHub Actions. When you push a version tag, the CI/CD pipeline: + +1. **Builds multi-platform Docker images** (amd64, arm64) +2. **Publishes to Docker Hub and GHCR** +3. **Packages and publishes Helm chart** to OCI registries +4. **Generates installation manifests** (`install.yaml`) +5. **Creates GitHub Release** with artifacts and release notes + +## πŸ“‹ Release Checklist + +### 1. Prepare Release + +- [ ] Ensure all tests pass: `make test-docker` +- [ ] Run linter: `make lint-docker` +- [ ] Update [`Chart.yaml`](deploy/charts/yanet-operator/Chart.yaml) version +- [ ] Update documentation if needed +- [ ] Commit all changes + +### 2. Create Release Tag + +```bash +# Set version (without 'v' prefix in variables) +VERSION="0.1.7" + +# Create and push tag +git tag -a "v${VERSION}" -m "Release v${VERSION}" +git push origin "v${VERSION}" +``` + +### 3. Monitor Release Pipeline + +GitHub Actions will automatically: + +1. **Build Docker images** β€” [`docker` job](.github/workflows/release.yml) + - Platforms: `linux/amd64`, `linux/arm64` + - Registries: Docker Hub, GHCR + - Tags: `v0.1.7`, `0.1.7`, `0.1`, `0`, `latest` + +2. **Package Helm chart** β€” [`helm` job](.github/workflows/release.yml) + - Syncs `appVersion` with git tag + - Publishes to Docker Hub OCI and GHCR + +3. **Generate manifests** β€” [`manifests` job](.github/workflows/release.yml) + - Creates `install.yaml` with all resources + +4. **Create GitHub Release** β€” [`release` job](.github/workflows/release.yml) + - Generates changelog from git commits + - Attaches artifacts (Helm chart, install.yaml) + - Publishes release notes + +### 4. Verify Release + +```bash +# Check Docker images +docker pull yanetplatform/yanet-operator:0.1.7 +docker pull ghcr.io/yanet-platform/yanet-operator:0.1.7 + +# Verify multi-platform support +docker manifest inspect yanetplatform/yanet-operator:0.1.7 + +# Check Helm chart +helm show chart oci://registry-1.docker.io/yanetplatform/yanet-operator --version 0.1.7 + +# Test installation +kubectl apply -f https://github.com/yanet-platform/yanet-operator/releases/download/v0.1.7/install.yaml +``` + +## πŸ“¦ Release Artifacts + +Each release includes: + +### Docker Images + +**Docker Hub:** +- `yanetplatform/yanet-operator:0.1.7` (version) +- `yanetplatform/yanet-operator:0.1` (minor) +- `yanetplatform/yanet-operator:0` (major) +- `yanetplatform/yanet-operator:latest` + +**GitHub Container Registry:** +- `ghcr.io/yanet-platform/yanet-operator:0.1.7` +- `ghcr.io/yanet-platform/yanet-operator:0.1` +- `ghcr.io/yanet-platform/yanet-operator:0` +- `ghcr.io/yanet-platform/yanet-operator:latest` + +**Platforms:** `linux/amd64`, `linux/arm64` + +### Helm Charts + +**Docker Hub OCI:** +```bash +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --version 0.1.7 \ + --namespace yanet-system \ + --create-namespace +``` + +**GitHub Container Registry:** +```bash +helm install yanet-operator \ + oci://ghcr.io/yanet-platform/yanet-operator \ + --version 0.1.7 \ + --namespace yanet-system \ + --create-namespace +``` + +### Kubernetes Manifests + +**install.yaml** β€” Complete installation manifest: +```bash +kubectl apply -f https://github.com/yanet-platform/yanet-operator/releases/download/v0.1.7/install.yaml +``` + +Contains: +- Custom Resource Definitions (CRDs) +- Namespace +- ServiceAccount, Role, RoleBinding +- Deployment +- Service (metrics, webhooks) +- ValidatingWebhookConfiguration + +## πŸ”„ Versioning Strategy + +We follow [Semantic Versioning](https://semver.org/): + +- **MAJOR** (0.x.x) β€” Incompatible API changes +- **MINOR** (x.1.x) β€” New features, backward compatible +- **PATCH** (x.x.7) β€” Bug fixes, backward compatible + +### Version Synchronization + +- **Git tag:** `v0.1.7` (with 'v' prefix) +- **Chart version:** `0.1.7` (in [`Chart.yaml`](deploy/charts/yanet-operator/Chart.yaml)) +- **Chart appVersion:** `0.1.7` (auto-synced from git tag) +- **Docker image tag:** `0.1.7` (extracted from git tag) + +## πŸ› οΈ Manual Release (Emergency) + +If automated release fails, you can release manually: + +### 1. Build and Push Docker Images + +```bash +VERSION="0.1.7" + +# Build multi-platform image +docker buildx create --use +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag yanetplatform/yanet-operator:${VERSION} \ + --tag yanetplatform/yanet-operator:latest \ + --tag ghcr.io/yanet-platform/yanet-operator:${VERSION} \ + --tag ghcr.io/yanet-platform/yanet-operator:latest \ + --push \ + . +``` + +### 2. Package and Push Helm Chart + +```bash +# Update appVersion in Chart.yaml +sed -i "s/^appVersion:.*/appVersion: \"${VERSION}\"/" deploy/charts/yanet-operator/Chart.yaml + +# Package chart +helm package deploy/charts/yanet-operator + +# Push to Docker Hub +helm registry login registry-1.docker.io +helm push yanet-operator-${VERSION}.tgz oci://registry-1.docker.io/yanetplatform + +# Push to GHCR +helm registry login ghcr.io +helm push yanet-operator-${VERSION}.tgz oci://ghcr.io/yanet-platform +``` + +### 3. Generate install.yaml + +```bash +make manifests +make build-installer IMG=yanetplatform/yanet-operator:${VERSION} +``` + +### 4. Create GitHub Release + +```bash +gh release create v${VERSION} \ + --title "Release v${VERSION}" \ + --notes "Manual release v${VERSION}" \ + dist/install.yaml \ + yanet-operator-${VERSION}.tgz +``` + +## πŸ” Troubleshooting + +### Release Pipeline Fails + +**Check GitHub Actions logs:** +```bash +gh run list --workflow=release.yml +gh run view --log +``` + +**Common issues:** + +1. **Docker Hub authentication fails** + - Verify `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets + - Check token permissions (read, write, delete) + +2. **GHCR authentication fails** + - Ensure `packages: write` permission in workflow + - Verify `GITHUB_TOKEN` has correct scopes + +3. **Helm push fails** + - Check chart version in `Chart.yaml` + - Verify OCI registry credentials + - Ensure chart version doesn't already exist + +4. **Manifest generation fails** + - Run `make manifests` locally to check for errors + - Verify kustomize installation + +### Version Mismatch + +If `Chart.yaml` version doesn't match git tag: + +```bash +# Update Chart.yaml manually +vim deploy/charts/yanet-operator/Chart.yaml + +# Commit and re-tag +git add deploy/charts/yanet-operator/Chart.yaml +git commit -m "Update chart version to 0.1.7" +git tag -d v0.1.7 +git push origin :refs/tags/v0.1.7 +git tag -a v0.1.7 -m "Release v0.1.7" +git push origin v0.1.7 +``` + +### Rollback Release + +To delete a release: + +```bash +VERSION="0.1.7" + +# Delete GitHub release +gh release delete v${VERSION} --yes + +# Delete git tag +git tag -d v${VERSION} +git push origin :refs/tags/v${VERSION} + +# Delete Docker images (manual via Docker Hub/GHCR UI) +# Delete Helm charts (manual via registry UI) +``` + +## πŸ“Š Release Metrics + +Track release health: + +- **Docker Hub pulls:** https://hub.docker.com/r/yanetplatform/yanet-operator +- **GHCR downloads:** https://github.com/yanet-platform/yanet-operator/pkgs/container/yanet-operator +- **GitHub releases:** https://github.com/yanet-platform/yanet-operator/releases +- **Helm chart versions:** `helm search repo yanet-operator --versions` + +## πŸ” Required Secrets + +GitHub repository secrets: + +| Secret | Description | Required For | +|--------|-------------|--------------| +| `DOCKERHUB_USERNAME` | Docker Hub username | Docker image publishing | +| `DOCKERHUB_TOKEN` | Docker Hub access token | Docker image publishing | +| `GITHUB_TOKEN` | Auto-provided by GitHub | GHCR, releases | + +**Setup Docker Hub token:** +1. Go to https://hub.docker.com/settings/security +2. Create new access token with read/write/delete permissions +3. Add to GitHub: Settings β†’ Secrets β†’ Actions β†’ New repository secret + +## πŸ“š Related Documentation + +- [README](README.md) β€” Project overview +- [Testing Guide](README_TESTS.md) β€” How to run tests +- [Validation Webhooks](README_WEBHOOKS.md) β€” Admission control +- [Prometheus Metrics](README_METRICS.md) β€” Monitoring +- [Architecture Analysis](ARCHITECTURE_ANALYSIS.md) β€” Design decisions + +## πŸŽ“ Best Practices + +1. **Always test before releasing** + ```bash + make test-docker + make lint-docker + ``` + +2. **Update Chart.yaml version first** + - Commit version bump separately + - Tag after version is committed + +3. **Use semantic versioning** + - Breaking changes β†’ major version + - New features β†’ minor version + - Bug fixes β†’ patch version + +4. **Write meaningful release notes** + - Highlight breaking changes + - List new features + - Document bug fixes + +5. **Verify multi-platform images** + ```bash + docker manifest inspect yanetplatform/yanet-operator:0.1.7 + ``` + +6. **Test Helm chart installation** + ```bash + helm install test oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --version 0.1.7 \ + --namespace test \ + --create-namespace \ + --dry-run + ``` + +## πŸš€ Quick Release + +For experienced maintainers: + +```bash +# 1. Update version +vim deploy/charts/yanet-operator/Chart.yaml +git add deploy/charts/yanet-operator/Chart.yaml +git commit -m "Bump version to 0.1.7" + +# 2. Test +make test-docker && make lint-docker + +# 3. Tag and push +git tag -a v0.1.7 -m "Release v0.1.7" +git push origin main v0.1.7 + +# 4. Monitor +gh run watch + +# 5. Verify +docker pull yanetplatform/yanet-operator:0.1.7 +helm show chart oci://registry-1.docker.io/yanetplatform/yanet-operator --version 0.1.7 +``` + +--- + +**Last Updated:** 2026-04-30 +**Workflow:** [`.github/workflows/release.yml`](.github/workflows/release.yml) diff --git a/README_TESTS.md b/README_TESTS.md new file mode 100644 index 0000000..c5a4520 --- /dev/null +++ b/README_TESTS.md @@ -0,0 +1,66 @@ +# Testing yanet-operator + +## πŸ“Š Current Status + +- **Coverage:** 93.2% overall (94.0% helpers, 92.4% manifests, 36.3% controller) +- **Unit tests:** 46 tests, all passing +- **Integration tests:** 4 scenarios +- **CI/CD:** GitHub Actions (tests + Docker + Helm) + +## πŸ§ͺ Running Tests + +### Quick Start (Docker-based, recommended) +```bash +make test-unit # Unit tests only +make test-docker # All tests in Docker +make test-docker-race # With race detector +``` + +### Local (requires Go 1.26.2+) +```bash +make test # All tests with coverage +make test-race # With race detector +make lint # Code quality checks +``` + +## πŸ“ Test Structure + +``` +internal/ +β”œβ”€β”€ helpers/ +β”‚ β”œβ”€β”€ helpers_test.go # 7 tests (NEW: GetLabeledNodes, DeploymentDiff edge cases, GetNodes) +β”‚ └── http_getters_test.go # 5 tests (HTTP client) +β”œβ”€β”€ manifests/ +β”‚ β”œβ”€β”€ builder.go # NEW: DeploymentBuilder (280 lines) +β”‚ β”œβ”€β”€ dataplane_test.go # 3 tests +β”‚ β”œβ”€β”€ controlplane_test.go # 4 tests +β”‚ β”œβ”€β”€ announcer_test.go # 3 tests +β”‚ β”œβ”€β”€ bird_test.go # 4 tests +β”‚ └── helpers_test.go # 6 tests +└── controller/ + β”œβ”€β”€ yanet_reconciler_test.go # NEW: 2 tests (checkUpdateRequeue) + β”œβ”€β”€ yanet_conditions_test.go # NEW: 7 tests (computeConditions) + β”œβ”€β”€ node_deletion_test.go # NEW: 4 tests (handleNodeDeletion) + └── yanet_controller_integration_test.go # 4 scenarios +``` + +## 🎯 Coverage Goals + +| Package | Current | Target | Status | +|---------|---------|--------|--------| +| manifests | 92.4% | 90%+ | βœ… | +| helpers | 94.0% | 70%+ | βœ… | +| controller | 36.3% (unit tests added) | 70%+ | πŸ”„ | + +## πŸš€ Next Steps + +### Priority 1: Validation Webhooks +- Create webhook for Yanet CRD +- Create webhook for YanetConfig CRD +- Add webhook tests + +## πŸ“š References + +- [Kubebuilder Testing](https://book.kubebuilder.io/cronjob-tutorial/writing-tests.html) +- [Ginkgo/Gomega](https://onsi.github.io/ginkgo/) +- [Controller-runtime Testing](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/envtest) diff --git a/README_WEBHOOKS.md b/README_WEBHOOKS.md new file mode 100644 index 0000000..d5374fa --- /dev/null +++ b/README_WEBHOOKS.md @@ -0,0 +1,220 @@ +# Validation Webhooks for yanet-operator + +## Overview + +yanet-operator uses Kubernetes Admission Webhooks to validate `Yanet` and `YanetConfig` resources before creation or update. + +## Yanet Validation + +### Validation Rules + +1. **spec.nodename** β€” required field, cannot be empty +2. **spec.nodename** β€” immutable (cannot be changed after creation) +3. **spec.type** β€” must be either `release` or `balancer` + +### Examples + +**Valid Yanet:** +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: Yanet +metadata: + name: yanet-node1 +spec: + nodename: node1.example.com + type: release +``` + +**Invalid Yanet (empty nodename):** +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: Yanet +metadata: + name: yanet-node1 +spec: + nodename: "" # ❌ Error: spec.nodename cannot be empty + type: release +``` + +**Invalid Yanet (wrong type):** +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: Yanet +metadata: + name: yanet-node1 +spec: + nodename: node1.example.com + type: custom # ❌ Error: spec.type must be either 'release' or 'balancer' +``` + +**Attempt to change nodename:** +```bash +# Create +kubectl apply -f yanet.yaml # βœ… OK + +# Try to change nodename +# ❌ Error: spec.nodename is immutable +``` + +## YanetConfig Validation + +### Validation Rules + +1. **spec.updatewindow** β€” must be >= 0 + +### Warnings + +Webhook may issue warnings (non-blocking): + +1. If `spec.stop = true` β€” "Stop is enabled - operator will not reconcile resources" +2. If `spec.autodiscovery.enable = true` but `typeuri` is not set +3. If `spec.autodiscovery.enable = true` but `namespace` is not set + +### Examples + +**Valid YanetConfig:** +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: YanetConfig +metadata: + name: global-config +spec: + updatewindow: 60 + stop: false +``` + +**Invalid YanetConfig:** +```yaml +apiVersion: yanet.yanet-platform.io/v1alpha1 +kind: YanetConfig +metadata: + name: global-config +spec: + updatewindow: -10 # ❌ Error: spec.updatewindow must be >= 0 +``` + +## Installation via Helm + +Webhook is enabled by default in Helm chart: + +```yaml +# values.yaml +webhook: + enabled: true + port: 9443 + certManager: + enabled: false # Use cert-manager for certificate generation + certGen: + image: + repository: registry.k8s.io/ingress-nginx/kube-webhook-certgen + tag: v1.5.2 +``` + +### Certificate Generation + +By default, `kube-webhook-certgen` is used for automatic certificate generation: + +1. **Pre-install/Pre-upgrade hook** β€” creates TLS certificates in Secret +2. **Post-install/Post-upgrade hook** β€” updates CA bundle in ValidatingWebhookConfiguration + +### Using cert-manager + +If you have cert-manager installed: + +```yaml +webhook: + enabled: true + certManager: + enabled: true +``` + +## Disabling Webhook + +To disable webhook: + +```yaml +webhook: + enabled: false +``` + +## Troubleshooting + +### Webhook not working + +1. Check that webhook service is available: +```bash +kubectl get svc -n yanet-operator yanet-operator-webhook-service +``` + +2. Check that certificates are created: +```bash +kubectl get secret -n yanet-operator yanet-operator-webhook-certs +``` + +3. Check ValidatingWebhookConfiguration: +```bash +kubectl get validatingwebhookconfiguration yanet-operator-validating-webhook-configuration -o yaml +``` + +4. Check operator logs: +```bash +kubectl logs -n yanet-operator deployment/yanet-operator +``` + +### Error "connection refused" + +Ensure that: +- Deployment is running and Ready +- Service is configured correctly +- Certificates are valid + +### Error "x509: certificate signed by unknown authority" + +CA bundle in ValidatingWebhookConfiguration is not updated. Run post-upgrade hook manually: + +```bash +kubectl delete job -n yanet-operator yanet-operator-webhook-update-ca +helm upgrade yanet-operator ./deploy/charts/yanet-operator +``` + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ kubectl apply β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Kubernetes API Server β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Admission Request + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ValidatingWebhookConfiguration β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ yanet-operator webhook β”‚ +β”‚ (port 9443) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Validate + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Yanet/ β”‚ +β”‚ YanetConfig β”‚ +β”‚ Validator β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Files + +- [`api/v1alpha1/yanet_webhook.go`](api/v1alpha1/yanet_webhook.go) β€” Yanet validation +- [`api/v1alpha1/yanetconfig_webhook.go`](api/v1alpha1/yanetconfig_webhook.go) β€” YanetConfig validation +- [`deploy/charts/yanet-operator/templates/webhook-service.yaml`](deploy/charts/yanet-operator/templates/webhook-service.yaml) β€” Service for webhook +- [`deploy/charts/yanet-operator/templates/webhook-cert-jobs.yaml`](deploy/charts/yanet-operator/templates/webhook-cert-jobs.yaml) β€” Jobs for certificate generation +- [`deploy/charts/yanet-operator/templates/webhook-configuration.yaml`](deploy/charts/yanet-operator/templates/webhook-configuration.yaml) β€” ValidatingWebhookConfiguration +- [`config/webhook/manifests.yaml`](config/webhook/manifests.yaml) β€” Generated webhook manifest diff --git a/api/v1alpha1/yanet_types.go b/api/v1alpha1/yanet_types.go index 66af8dc..39e0758 100644 --- a/api/v1alpha1/yanet_types.go +++ b/api/v1alpha1/yanet_types.go @@ -69,7 +69,8 @@ type Dep struct { // You can make deployment with zero replicas with this option. // Default: true // +kubebuilder:default=true - Enable bool `json:"enable,omitempty"` + // +optional + Enable bool `json:"enable"` // image name. Image string `json:"image,omitempty"` // (Optional) image tag. @@ -81,6 +82,9 @@ type YanetStatus struct { // Resulting pods by status. Pods map[v1.PodPhase][]string `json:"pods,omitempty"` Sync Sync `json:"sync,omitempty"` + // Conditions represent the latest available observations of the Yanet's state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` } // Sync defines sync state of Yanet objects. diff --git a/api/v1alpha1/yanet_webhook.go b/api/v1alpha1/yanet_webhook.go new file mode 100644 index 0000000..ebf7537 --- /dev/null +++ b/api/v1alpha1/yanet_webhook.go @@ -0,0 +1,81 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var yanetlog = logf.Log.WithName("yanet-webhook") + +// SetupWebhookWithManager will setup the manager to manage the webhooks +func (r *Yanet) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, r). + Complete() +} + +//+kubebuilder:webhook:path=/validate-yanet-yanet-platform-io-v1alpha1-yanet,mutating=false,failurePolicy=fail,sideEffects=None,groups=yanet.yanet-platform.io,resources=yanets,verbs=create;update,versions=v1alpha1,name=vyanet.kb.io,admissionReviewVersions=v1 + +var _ admission.Validator[*Yanet] = &Yanet{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *Yanet) ValidateCreate(ctx context.Context, obj *Yanet) (admission.Warnings, error) { + yanetlog.Info("validate create", "name", obj.Name) + + return obj.validateYanet() +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *Yanet) ValidateUpdate(ctx context.Context, oldObj, newObj *Yanet) (admission.Warnings, error) { + yanetlog.Info("validate update", "name", newObj.Name) + + // Check immutable fields + if newObj.Spec.NodeName != oldObj.Spec.NodeName { + return nil, fmt.Errorf("spec.nodename is immutable") + } + + return newObj.validateYanet() +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *Yanet) ValidateDelete(ctx context.Context, obj *Yanet) (admission.Warnings, error) { + yanetlog.Info("validate delete", "name", obj.Name) + + // No validation needed for delete + return nil, nil +} + +// validateYanet performs common validation for Yanet +func (r *Yanet) validateYanet() (admission.Warnings, error) { + // Validate Type field + if r.Spec.Type != "" && r.Spec.Type != "release" && r.Spec.Type != "balancer" { + return nil, fmt.Errorf("spec.type must be either 'release' or 'balancer', got '%s'", r.Spec.Type) + } + + // Validate NodeName is not empty + if r.Spec.NodeName == "" { + return nil, fmt.Errorf("spec.nodename cannot be empty") + } + + return nil, nil +} diff --git a/api/v1alpha1/yanet_webhook_test.go b/api/v1alpha1/yanet_webhook_test.go new file mode 100644 index 0000000..72fd1f0 --- /dev/null +++ b/api/v1alpha1/yanet_webhook_test.go @@ -0,0 +1,253 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestYanetValidateCreate(t *testing.T) { + tests := []struct { + name string + yanet *Yanet + wantErr bool + errMsg string + }{ + { + name: "valid yanet with release type", + yanet: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + }, + }, + wantErr: false, + }, + { + name: "valid yanet with balancer type", + yanet: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "balancer", + }, + }, + wantErr: false, + }, + { + name: "valid yanet with empty type (default)", + yanet: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "", + }, + }, + wantErr: false, + }, + { + name: "invalid yanet with empty nodename", + yanet: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "", + Type: "release", + }, + }, + wantErr: true, + errMsg: "spec.nodename cannot be empty", + }, + { + name: "invalid yanet with wrong type", + yanet: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "custom", + }, + }, + wantErr: true, + errMsg: "spec.type must be either 'release' or 'balancer', got 'custom'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + _, err := tt.yanet.ValidateCreate(ctx, tt.yanet) + + if (err != nil) != tt.wantErr { + t.Errorf("ValidateCreate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err.Error() != tt.errMsg { + t.Errorf("ValidateCreate() error message = %v, want %v", err.Error(), tt.errMsg) + } + }) + } +} + +func TestYanetValidateUpdate(t *testing.T) { + tests := []struct { + name string + old *Yanet + new *Yanet + wantErr bool + errMsg string + }{ + { + name: "valid update - same nodename", + old: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + }, + }, + new: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + AutoSync: true, + }, + }, + wantErr: false, + }, + { + name: "valid update - change type", + old: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + }, + }, + new: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "balancer", + }, + }, + wantErr: false, + }, + { + name: "invalid update - change nodename", + old: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + }, + }, + new: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node2", + Type: "release", + }, + }, + wantErr: true, + errMsg: "spec.nodename is immutable", + }, + { + name: "invalid update - empty nodename in new", + old: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + }, + }, + new: &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "", + Type: "release", + }, + }, + wantErr: true, + errMsg: "spec.nodename is immutable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + _, err := tt.new.ValidateUpdate(ctx, tt.old, tt.new) + + if (err != nil) != tt.wantErr { + t.Errorf("ValidateUpdate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err.Error() != tt.errMsg { + t.Errorf("ValidateUpdate() error message = %v, want %v", err.Error(), tt.errMsg) + } + }) + } +} + +func TestYanetValidateDelete(t *testing.T) { + yanet := &Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + }, + Spec: YanetSpec{ + NodeName: "node1", + Type: "release", + }, + } + + ctx := context.Background() + _, err := yanet.ValidateDelete(ctx, yanet) + if err != nil { + t.Errorf("ValidateDelete() should not return error, got %v", err) + } +} diff --git a/api/v1alpha1/yanetconfig_types.go b/api/v1alpha1/yanetconfig_types.go index e653e7b..d41105d 100644 --- a/api/v1alpha1/yanetconfig_types.go +++ b/api/v1alpha1/yanetconfig_types.go @@ -39,9 +39,9 @@ type YanetConfigSpec struct { Stop bool `json:"stop,omitempty"` // (Optional) AutoDiscovery configure new worker node initializer. AutoDiscovery AutoDiscovery `json:"autodiscovery,omitempty"` - // (Optional) Period in seconds between yanet resources reconcilation on different nodes. - // When the value is non-zero, the operator expects number of seconds between end of resource reconcilation on one node - // and resource reconcilation start on another node. + // (Optional) Period in seconds between yanet resources reconciliation on different nodes. + // When the value is non-zero, the operator expects number of seconds between end of resource reconciliation on one node + // and resource reconciliation start on another node. // Default: 0 // +kubebuilder:default=0 UpdateWindow int `json:"updatewindow,omitempty"` diff --git a/api/v1alpha1/yanetconfig_webhook.go b/api/v1alpha1/yanetconfig_webhook.go new file mode 100644 index 0000000..a3daa57 --- /dev/null +++ b/api/v1alpha1/yanetconfig_webhook.go @@ -0,0 +1,88 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var yanetconfiglog = logf.Log.WithName("yanetconfig-webhook") + +// SetupWebhookWithManager will setup the manager to manage the webhooks +func (r *YanetConfig) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, r). + Complete() +} + +//+kubebuilder:webhook:path=/validate-yanet-yanet-platform-io-v1alpha1-yanetconfig,mutating=false,failurePolicy=fail,sideEffects=None,groups=yanet.yanet-platform.io,resources=yanetconfigs,verbs=create;update,versions=v1alpha1,name=vyanetconfig.kb.io,admissionReviewVersions=v1 + +var _ admission.Validator[*YanetConfig] = &YanetConfig{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *YanetConfig) ValidateCreate(ctx context.Context, obj *YanetConfig) (admission.Warnings, error) { + yanetconfiglog.Info("validate create", "name", obj.Name) + + return obj.validateYanetConfig() +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *YanetConfig) ValidateUpdate(ctx context.Context, oldObj, newObj *YanetConfig) (admission.Warnings, error) { + yanetconfiglog.Info("validate update", "name", newObj.Name) + + return newObj.validateYanetConfig() +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *YanetConfig) ValidateDelete(ctx context.Context, obj *YanetConfig) (admission.Warnings, error) { + yanetconfiglog.Info("validate delete", "name", obj.Name) + + // No validation needed for delete + return nil, nil +} + +// validateYanetConfig performs common validation for YanetConfig +func (r *YanetConfig) validateYanetConfig() (admission.Warnings, error) { + var warnings admission.Warnings + + // Validate UpdateWindow is non-negative + if r.Spec.UpdateWindow < 0 { + return nil, fmt.Errorf("spec.updatewindow must be >= 0, got %d", r.Spec.UpdateWindow) + } + + // Warn if Stop is enabled + if r.Spec.Stop { + warnings = append(warnings, "Stop is enabled - operator will not reconcile resources") + } + + // Warn if AutoDiscovery is enabled without required URIs + if r.Spec.AutoDiscovery.Enable { + if r.Spec.AutoDiscovery.TypeUri == "" { + warnings = append(warnings, "AutoDiscovery is enabled but TypeUri is not set") + } + if r.Spec.AutoDiscovery.Namespace == "" { + warnings = append(warnings, "AutoDiscovery is enabled but Namespace is not set, using default") + } + } + + return warnings, nil +} diff --git a/api/v1alpha1/yanetconfig_webhook_test.go b/api/v1alpha1/yanetconfig_webhook_test.go new file mode 100644 index 0000000..11e6086 --- /dev/null +++ b/api/v1alpha1/yanetconfig_webhook_test.go @@ -0,0 +1,235 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestYanetConfigValidateCreate(t *testing.T) { + tests := []struct { + name string + config *YanetConfig + wantErr bool + errMsg string + wantWarning bool + }{ + { + name: "valid config with positive updatewindow", + config: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 300, + Stop: false, + }, + }, + wantErr: false, + wantWarning: false, + }, + { + name: "valid config with zero updatewindow", + config: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 0, + Stop: false, + }, + }, + wantErr: false, + wantWarning: false, + }, + { + name: "invalid config with negative updatewindow", + config: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: -10, + Stop: false, + }, + }, + wantErr: true, + errMsg: "spec.updatewindow must be >= 0, got -10", + }, + { + name: "config with stop enabled (warning)", + config: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 0, + Stop: true, + }, + }, + wantErr: false, + wantWarning: true, + }, + { + name: "config with autodiscovery but no typeuri (warning)", + config: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 0, + AutoDiscovery: AutoDiscovery{ + Enable: true, + TypeUri: "", + }, + }, + }, + wantErr: false, + wantWarning: true, + }, + { + name: "config with autodiscovery but no namespace (warning)", + config: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 0, + AutoDiscovery: AutoDiscovery{ + Enable: true, + TypeUri: "http://example.com/type", + Namespace: "", + }, + }, + }, + wantErr: false, + wantWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + warnings, err := tt.config.ValidateCreate(ctx, tt.config) + + if (err != nil) != tt.wantErr { + t.Errorf("ValidateCreate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err.Error() != tt.errMsg { + t.Errorf("ValidateCreate() error message = %v, want %v", err.Error(), tt.errMsg) + } + + if tt.wantWarning && len(warnings) == 0 { + t.Errorf("ValidateCreate() expected warnings but got none") + } + + if !tt.wantWarning && len(warnings) > 0 { + t.Errorf("ValidateCreate() unexpected warnings: %v", warnings) + } + }) + } +} + +func TestYanetConfigValidateUpdate(t *testing.T) { + tests := []struct { + name string + old *YanetConfig + new *YanetConfig + wantErr bool + errMsg string + }{ + { + name: "valid update", + old: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 300, + }, + }, + new: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 600, + }, + }, + wantErr: false, + }, + { + name: "invalid update - negative updatewindow", + old: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 300, + }, + }, + new: &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: -5, + }, + }, + wantErr: true, + errMsg: "spec.updatewindow must be >= 0, got -5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + _, err := tt.new.ValidateUpdate(ctx, tt.old, tt.new) + + if (err != nil) != tt.wantErr { + t.Errorf("ValidateUpdate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err.Error() != tt.errMsg { + t.Errorf("ValidateUpdate() error message = %v, want %v", err.Error(), tt.errMsg) + } + }) + } +} + +func TestYanetConfigValidateDelete(t *testing.T) { + config := &YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + }, + Spec: YanetConfigSpec{ + UpdateWindow: 300, + }, + } + + ctx := context.Background() + _, err := config.ValidateDelete(ctx, config) + if err != nil { + t.Errorf("ValidateDelete() should not return error, got %v", err) + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4493ca3..27217fc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -22,7 +22,8 @@ package v1alpha1 import ( "k8s.io/api/core/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -456,6 +457,13 @@ func (in *YanetStatus) DeepCopyInto(out *YanetStatus) { } } in.Sync.DeepCopyInto(&out.Sync) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new YanetStatus. diff --git a/cmd/main.go b/cmd/main.go index 860a99b..57e05c4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -131,6 +131,7 @@ func main() { if err = (&controller.YanetReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("yanet-controller"), //nolint:staticcheck // SA1019: old API still works GlobalConfig: &GlobalConfig, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Yanet") @@ -144,6 +145,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "YanetConfig") os.Exit(1) } + + if err = (&yanetv1alpha1.Yanet{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Yanet") + os.Exit(1) + } + if err = (&yanetv1alpha1.YanetConfig{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "YanetConfig") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/yanet.yanet-platform.io_yanetconfigs.yaml b/config/crd/bases/yanet.yanet-platform.io_yanetconfigs.yaml index c9a211f..fddb960 100644 --- a/config/crd/bases/yanet.yanet-platform.io_yanetconfigs.yaml +++ b/config/crd/bases/yanet.yanet-platform.io_yanetconfigs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.20.1 name: yanetconfigs.yanet.yanet-platform.io spec: group: yanet.yanet-platform.io @@ -72,6 +72,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic command: description: |- Entrypoint array. Not executed within a shell. @@ -85,6 +86,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic env: description: |- List of environment variables to set in the container. @@ -94,8 +96,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -120,10 +123,13 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the ConfigMap @@ -150,6 +156,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -183,10 +226,13 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the Secret or @@ -201,26 +247,32 @@ spec: - name type: object type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map envFrom: description: |- List of sources to populate environment variables in the container. - The keys defined within a source must be a C_IDENTIFIER. All invalid keys - will be reported as an event when the container is starting. When a key exists in multiple + The keys defined within a source may consist of any printable ASCII characters except '='. + When a key exists in multiple sources, the value associated with the last source will take precedence. Values defined by an Env with a duplicate key will take precedence. Cannot be updated. items: description: EnvFromSource represents the source of a - set of ConfigMaps + set of ConfigMaps or Secrets properties: configMapRef: description: The ConfigMap to select from properties: name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the ConfigMap must @@ -229,17 +281,21 @@ spec: type: object x-kubernetes-map-type: atomic prefix: - description: An optional identifier to prepend to - each key in the ConfigMap. Must be a C_IDENTIFIER. + description: |- + Optional text to prepend to the name of each environment variable. + May consist of any printable ASCII characters except '='. type: string secretRef: description: The Secret to select from properties: name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the Secret must be @@ -249,6 +305,7 @@ spec: x-kubernetes-map-type: atomic type: object type: array + x-kubernetes-list-type: atomic image: description: |- Container image name. @@ -277,7 +334,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -289,9 +347,10 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -319,6 +378,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -340,8 +400,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that the + container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -354,8 +414,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -387,7 +447,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -399,9 +460,10 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -429,6 +491,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -450,8 +513,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that the + container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -464,8 +527,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -484,6 +547,12 @@ spec: - port type: object type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string type: object livenessProbe: description: |- @@ -493,7 +562,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -505,6 +575,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object failureThreshold: description: |- @@ -513,8 +584,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a GRPC - port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. Number @@ -522,18 +592,19 @@ spec: format: int32 type: integer service: + default: "" description: |- Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). - If this is not specified, the default behavior is defined by gRPC. type: string required: - port type: object httpGet: - description: HTTPGet specifies the http request to perform. + description: HTTPGet specifies an HTTP GET request to + perform. properties: host: description: |- @@ -560,6 +631,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -599,8 +671,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a TCP + port. properties: host: description: 'Optional: Host name to connect to, @@ -705,7 +777,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -717,6 +790,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object failureThreshold: description: |- @@ -725,8 +799,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a GRPC - port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. Number @@ -734,18 +807,19 @@ spec: format: int32 type: integer service: + default: "" description: |- Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). - If this is not specified, the default behavior is defined by gRPC. type: string required: - port type: object httpGet: - description: HTTPGet specifies the http request to perform. + description: HTTPGet specifies an HTTP GET request to + perform. properties: host: description: |- @@ -772,6 +846,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -811,8 +886,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a TCP + port. properties: host: description: 'Optional: Host name to connect to, @@ -853,7 +928,9 @@ spec: type: integer type: object resizePolicy: - description: Resources resize policy for the container. + description: |- + Resources resize policy for the container. + This field cannot be set on ephemeral containers. items: description: ContainerResizePolicy represents resource resize policy for the container. @@ -885,11 +962,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in @@ -901,6 +976,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -936,10 +1017,10 @@ spec: restartPolicy: description: |- RestartPolicy defines the restart behavior of individual containers in a pod. - This field may only be set for init containers, and the only allowed value is "Always". - For non-init containers or when this field is not specified, + This overrides the pod-level restart policy. When this field is not specified, the restart behavior is defined by the Pod's restart policy and the container type. - Setting the RestartPolicy as "Always" for the init container will have the following effect: + Additionally, setting the RestartPolicy as "Always" for the init container will + have the following effect: this init container will be continually restarted on exit until all regular containers have terminated. Once all regular containers have completed, all init containers with restartPolicy "Always" @@ -951,6 +1032,59 @@ spec: init container is started, or after any startupProbe has successfully completed. type: string + restartPolicyRules: + description: |- + Represents a list of rules to be checked to determine if the + container should be restarted on exit. The rules are evaluated in + order. Once a rule matches a container exit condition, the remaining + rules are ignored. If no rule matches the container exit condition, + the Container-level restart policy determines the whether the container + is restarted or not. Constraints on the rules: + - At most 20 rules are allowed. + - Rules can have the same action. + - Identical rules are not forbidden in validations. + When rules are specified, container MUST set RestartPolicy explicitly + even it if matches the Pod's RestartPolicy. + items: + description: ContainerRestartRule describes how a container + exit is handled. + properties: + action: + description: |- + Specifies the action taken on a container exit if the requirements + are satisfied. The only possible value is "Restart" to restart the + container. + type: string + exitCodes: + description: Represents the exit codes to check on + container exits. + properties: + operator: + description: |- + Represents the relationship between the container exit code(s) and the + specified values. Possible values are: + - In: the requirement is satisfied if the container exit code is in the + set of specified values. + - NotIn: the requirement is satisfied if the container exit code is + not in the set of specified values. + type: string + values: + description: |- + Specifies the set of values to check for container exit codes. + At most 255 elements are allowed. + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic securityContext: description: |- SecurityContext defines the security options the container should be run with. @@ -967,6 +1101,30 @@ spec: 2) has CAP_SYS_ADMIN Note that this field cannot be set when spec.os.name is windows. type: boolean + appArmorProfile: + description: |- + appArmorProfile is the AppArmor options to use by this container. If set, this profile + overrides the pod's appArmorProfile. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + type: string + type: + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + type: string + required: + - type + type: object capabilities: description: |- The capabilities to add/drop when running containers. @@ -980,6 +1138,7 @@ spec: type type: string type: array + x-kubernetes-list-type: atomic drop: description: Removed capabilities items: @@ -987,6 +1146,7 @@ spec: type type: string type: array + x-kubernetes-list-type: atomic type: object privileged: description: |- @@ -998,7 +1158,7 @@ spec: procMount: description: |- procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for + The default value is Default which uses the container runtime defaults for readonly paths and masked paths. This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. @@ -1080,7 +1240,6 @@ spec: type indicates which kind of seccomp profile will be applied. Valid options are: - Localhost - a profile defined in a file on the node should be used. RuntimeDefault - the container runtime default profile should be used. Unconfined - no profile should be applied. @@ -1132,7 +1291,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -1144,6 +1304,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object failureThreshold: description: |- @@ -1152,8 +1313,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a GRPC - port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. Number @@ -1161,18 +1321,19 @@ spec: format: int32 type: integer service: + default: "" description: |- Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). - If this is not specified, the default behavior is defined by gRPC. type: string required: - port type: object httpGet: - description: HTTPGet specifies the http request to perform. + description: HTTPGet specifies an HTTP GET request to + perform. properties: host: description: |- @@ -1199,6 +1360,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -1238,8 +1400,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a TCP + port. properties: host: description: 'Optional: Host name to connect to, @@ -1340,6 +1502,9 @@ spec: - name type: object type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map volumeMounts: description: |- Pod volumes to mount into the container's filesystem. @@ -1359,6 +1524,8 @@ spec: to container and the other way around. When not set, MountPropagationNone is used. This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). type: string name: description: This must match the Name of a Volume. @@ -1368,6 +1535,25 @@ spec: Mounted read-only if true, read-write otherwise (false or unspecified). Defaults to false. type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string subPath: description: |- Path within the volume from which the container's volume should be mounted. @@ -1385,6 +1571,9 @@ spec: - name type: object type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map workingDir: description: |- Container's working directory. @@ -1529,11 +1718,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1545,6 +1732,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1615,11 +1808,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1631,6 +1822,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1701,11 +1898,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1717,6 +1912,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1787,11 +1988,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1803,6 +2002,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1878,11 +2083,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1894,6 +2097,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1964,11 +2173,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1980,6 +2187,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2050,11 +2263,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -2066,6 +2277,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2136,11 +2353,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -2152,6 +2367,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2198,9 +2419,9 @@ spec: updatewindow: default: 0 description: |- - (Optional) Period in seconds between yanet resources reconcilation on different nodes. - When the value is non-zero, the operator expects number of seconds between end of resource reconcilation on one node - and resource reconcilation start on another node. + (Optional) Period in seconds between yanet resources reconciliation on different nodes. + When the value is non-zero, the operator expects number of seconds between end of resource reconciliation on one node + and resource reconciliation start on another node. Default: 0 type: integer type: object diff --git a/config/crd/bases/yanet.yanet-platform.io_yanets.yaml b/config/crd/bases/yanet.yanet-platform.io_yanets.yaml index e9b4162..54c10f0 100644 --- a/config/crd/bases/yanet.yanet-platform.io_yanets.yaml +++ b/config/crd/bases/yanet.yanet-platform.io_yanets.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.20.1 name: yanets.yanet.yanet-platform.io spec: group: yanet.yanet-platform.io @@ -161,6 +161,64 @@ spec: status: description: YanetStatus defines the observed state of Yanet. properties: + conditions: + description: Conditions represent the latest available observations + of the Yanet's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array pods: additionalProperties: items: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 474d808..6edb92e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,42 +4,6 @@ kind: ClusterRole metadata: name: manager-role rules: -- apiGroups: - - apps - resources: - - deployments - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - coordination.k8s.io - resources: - - configmap - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - coordination.k8s.io - resources: - - leases - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - apiGroups: - "" resources: @@ -51,22 +15,15 @@ rules: - "" resources: - nodes - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - pods verbs: - get - list - watch - apiGroups: - - yanet.yanet-platform.io + - apps resources: - - yanetconfigs + - deployments verbs: - create - delete @@ -76,22 +33,22 @@ rules: - update - watch - apiGroups: - - yanet.yanet-platform.io - resources: - - yanetconfigs/finalizers - verbs: - - update -- apiGroups: - - yanet.yanet-platform.io + - coordination.k8s.io resources: - - yanetconfigs/status + - configmap + - leases verbs: + - create + - delete - get + - list - patch - update + - watch - apiGroups: - yanet.yanet-platform.io resources: + - yanetconfigs - yanets verbs: - create @@ -104,12 +61,14 @@ rules: - apiGroups: - yanet.yanet-platform.io resources: + - yanetconfigs/finalizers - yanets/finalizers verbs: - update - apiGroups: - yanet.yanet-platform.io resources: + - yanetconfigs/status - yanets/status verbs: - get diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 0000000..4fef9fe --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,46 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-yanet-yanet-platform-io-v1alpha1-yanet + failurePolicy: Fail + name: vyanet.kb.io + rules: + - apiGroups: + - yanet.yanet-platform.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - yanets + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-yanet-yanet-platform-io-v1alpha1-yanetconfig + failurePolicy: Fail + name: vyanetconfig.kb.io + rules: + - apiGroups: + - yanet.yanet-platform.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - yanetconfigs + sideEffects: None diff --git a/deploy/charts/yanet-operator/Chart.yaml b/deploy/charts/yanet-operator/Chart.yaml index 95424a2..ee3a644 100644 --- a/deploy/charts/yanet-operator/Chart.yaml +++ b/deploy/charts/yanet-operator/Chart.yaml @@ -1,6 +1,12 @@ apiVersion: v2 name: yanet-operator -description: A Helm chart for Kubernetes +description: A Helm chart for yanet-operator - Kubernetes operator for YANET deployments type: application -version: 0.1.2 -appVersion: "0.15" +# Version is set manually and used for Helm chart versioning +version: 0.1.6 +# appVersion is automatically synced with version during CI/CD build +# It determines the Docker image tag: yanetplatform/yanet-operator: +appVersion: "0.0.0-dev" +maintainers: + - name: yanet-platform + url: https://github.com/yanet-platform diff --git a/deploy/charts/yanet-operator/README.md b/deploy/charts/yanet-operator/README.md new file mode 100644 index 0000000..024e5f4 --- /dev/null +++ b/deploy/charts/yanet-operator/README.md @@ -0,0 +1,162 @@ +# Yanet Operator Helm Chart + +Kubernetes operator for managing YANET (Yet Another Network) deployments on worker nodes. + +## Installation + +```bash +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --version 0.1.6 \ + --namespace yanet-system \ + --create-namespace +``` + +## Configuration + +### Metrics and Monitoring + +Enable Prometheus metrics collection: + +```yaml +metrics: + enabled: true + serviceMonitor: + enabled: true + interval: 30s + release: kube-prometheus-stack +``` + +### Grafana Dashboard + +Enable automatic Grafana dashboard deployment: + +```yaml +grafana: + dashboards: + enabled: true + namespace: kube-mon # Namespace where Grafana is installed + labels: + grafana_dashboard: "1" # Label for Grafana sidecar discovery +``` + +The dashboard will be automatically discovered if you're using Grafana with sidecar enabled: + +```yaml +# Grafana Helm values +sidecar: + dashboards: + enabled: true + label: grafana_dashboard +``` + +### Webhooks + +Enable validation webhooks: + +```yaml +webhook: + enabled: true + port: 9443 + certManager: + enabled: false # Set to true if using cert-manager +``` + +### YanetConfig + +Configure global YanetConfig resource: + +```yaml +yanetconfig: + spec: + autodiscovery: + enable: false + namespace: yanet + registry: dockerhub.io + stop: false +``` + +## Values + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `replicaCount` | Number of operator replicas | `2` | +| `image.repository` | Operator image repository | `yanetplatform/yanet-operator` | +| `image.pullPolicy` | Image pull policy | `IfNotPresent` | +| `metrics.enabled` | Enable Prometheus metrics | `true` | +| `metrics.serviceMonitor.enabled` | Create ServiceMonitor | `true` | +| `metrics.serviceMonitor.interval` | Scrape interval | `30s` | +| `metrics.serviceMonitor.release` | Prometheus Operator release label | `kube-prometheus-stack` | +| `grafana.dashboards.enabled` | Deploy Grafana dashboard | `true` | +| `grafana.dashboards.namespace` | Dashboard ConfigMap namespace | `kube-mon` | +| `grafana.dashboards.labels` | Labels for dashboard discovery | `{"grafana_dashboard": "1"}` | +| `webhook.enabled` | Enable validation webhooks | `true` | +| `webhook.port` | Webhook server port | `9443` | +| `webhook.certManager.enabled` | Use cert-manager for certificates | `false` | +| `resources.limits.cpu` | CPU limit | `4` | +| `resources.limits.memory` | Memory limit | `4Gi` | +| `resources.requests.cpu` | CPU request | `2` | +| `resources.requests.memory` | Memory request | `2Gi` | + +## Examples + +### Minimal Installation + +```bash +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --namespace yanet-system \ + --create-namespace +``` + +### With Custom Namespace for Grafana + +```bash +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --namespace yanet-system \ + --create-namespace \ + --set grafana.dashboards.namespace=monitoring +``` + +### Disable Metrics and Dashboard + +```bash +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --namespace yanet-system \ + --create-namespace \ + --set metrics.enabled=false \ + --set grafana.dashboards.enabled=false +``` + +### With cert-manager + +```bash +helm install yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --namespace yanet-system \ + --create-namespace \ + --set webhook.certManager.enabled=true +``` + +## Upgrading + +```bash +helm upgrade yanet-operator \ + oci://registry-1.docker.io/yanetplatform/yanet-operator \ + --namespace yanet-system +``` + +## Uninstalling + +```bash +helm uninstall yanet-operator --namespace yanet-system +``` + +## Documentation + +- [Prometheus Metrics](../../README_METRICS.md) +- [Validation Webhooks](../../README_WEBHOOKS.md) +- [Testing Guide](../../README_TESTS.md) +- [Architecture Analysis](../../ARCHITECTURE_ANALYSIS.md) diff --git a/deploy/charts/yanet-operator/crds/yanet.yaml b/deploy/charts/yanet-operator/crds/yanet.yaml index 852f961..50f9019 100644 --- a/deploy/charts/yanet-operator/crds/yanet.yaml +++ b/deploy/charts/yanet-operator/crds/yanet.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.20.1 name: yanetconfigs.yanet.yanet-platform.io spec: group: yanet.yanet-platform.io @@ -71,6 +71,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic command: description: |- Entrypoint array. Not executed within a shell. @@ -84,6 +85,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic env: description: |- List of environment variables to set in the container. @@ -93,8 +95,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -119,10 +122,13 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the ConfigMap @@ -149,6 +155,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -182,10 +225,13 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the Secret or @@ -200,26 +246,32 @@ spec: - name type: object type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map envFrom: description: |- List of sources to populate environment variables in the container. - The keys defined within a source must be a C_IDENTIFIER. All invalid keys - will be reported as an event when the container is starting. When a key exists in multiple + The keys defined within a source may consist of any printable ASCII characters except '='. + When a key exists in multiple sources, the value associated with the last source will take precedence. Values defined by an Env with a duplicate key will take precedence. Cannot be updated. items: description: EnvFromSource represents the source of a - set of ConfigMaps + set of ConfigMaps or Secrets properties: configMapRef: description: The ConfigMap to select from properties: name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the ConfigMap must @@ -228,17 +280,21 @@ spec: type: object x-kubernetes-map-type: atomic prefix: - description: An optional identifier to prepend to - each key in the ConfigMap. Must be a C_IDENTIFIER. + description: |- + Optional text to prepend to the name of each environment variable. + May consist of any printable ASCII characters except '='. type: string secretRef: description: The Secret to select from properties: name: + default: "" description: |- Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? type: string optional: description: Specify whether the Secret must be @@ -248,6 +304,7 @@ spec: x-kubernetes-map-type: atomic type: object type: array + x-kubernetes-list-type: atomic image: description: |- Container image name. @@ -276,7 +333,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -288,9 +346,10 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -318,6 +377,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -339,8 +399,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that the + container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -353,8 +413,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -386,7 +446,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -398,9 +459,10 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -428,6 +490,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -449,8 +512,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that the + container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -463,8 +526,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -483,6 +546,12 @@ spec: - port type: object type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string type: object livenessProbe: description: |- @@ -492,7 +561,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -504,6 +574,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object failureThreshold: description: |- @@ -512,8 +583,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a GRPC - port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. Number @@ -521,18 +591,19 @@ spec: format: int32 type: integer service: + default: "" description: |- Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). - If this is not specified, the default behavior is defined by gRPC. type: string required: - port type: object httpGet: - description: HTTPGet specifies the http request to perform. + description: HTTPGet specifies an HTTP GET request to + perform. properties: host: description: |- @@ -559,6 +630,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -598,8 +670,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a TCP + port. properties: host: description: 'Optional: Host name to connect to, @@ -704,7 +776,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -716,6 +789,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object failureThreshold: description: |- @@ -724,8 +798,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a GRPC - port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. Number @@ -733,18 +806,19 @@ spec: format: int32 type: integer service: + default: "" description: |- Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). - If this is not specified, the default behavior is defined by gRPC. type: string required: - port type: object httpGet: - description: HTTPGet specifies the http request to perform. + description: HTTPGet specifies an HTTP GET request to + perform. properties: host: description: |- @@ -771,6 +845,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -810,8 +885,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a TCP + port. properties: host: description: 'Optional: Host name to connect to, @@ -852,7 +927,9 @@ spec: type: integer type: object resizePolicy: - description: Resources resize policy for the container. + description: |- + Resources resize policy for the container. + This field cannot be set on ephemeral containers. items: description: ContainerResizePolicy represents resource resize policy for the container. @@ -884,11 +961,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in @@ -900,6 +975,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -935,10 +1016,10 @@ spec: restartPolicy: description: |- RestartPolicy defines the restart behavior of individual containers in a pod. - This field may only be set for init containers, and the only allowed value is "Always". - For non-init containers or when this field is not specified, + This overrides the pod-level restart policy. When this field is not specified, the restart behavior is defined by the Pod's restart policy and the container type. - Setting the RestartPolicy as "Always" for the init container will have the following effect: + Additionally, setting the RestartPolicy as "Always" for the init container will + have the following effect: this init container will be continually restarted on exit until all regular containers have terminated. Once all regular containers have completed, all init containers with restartPolicy "Always" @@ -950,6 +1031,59 @@ spec: init container is started, or after any startupProbe has successfully completed. type: string + restartPolicyRules: + description: |- + Represents a list of rules to be checked to determine if the + container should be restarted on exit. The rules are evaluated in + order. Once a rule matches a container exit condition, the remaining + rules are ignored. If no rule matches the container exit condition, + the Container-level restart policy determines the whether the container + is restarted or not. Constraints on the rules: + - At most 20 rules are allowed. + - Rules can have the same action. + - Identical rules are not forbidden in validations. + When rules are specified, container MUST set RestartPolicy explicitly + even it if matches the Pod's RestartPolicy. + items: + description: ContainerRestartRule describes how a container + exit is handled. + properties: + action: + description: |- + Specifies the action taken on a container exit if the requirements + are satisfied. The only possible value is "Restart" to restart the + container. + type: string + exitCodes: + description: Represents the exit codes to check on + container exits. + properties: + operator: + description: |- + Represents the relationship between the container exit code(s) and the + specified values. Possible values are: + - In: the requirement is satisfied if the container exit code is in the + set of specified values. + - NotIn: the requirement is satisfied if the container exit code is + not in the set of specified values. + type: string + values: + description: |- + Specifies the set of values to check for container exit codes. + At most 255 elements are allowed. + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + type: object + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic securityContext: description: |- SecurityContext defines the security options the container should be run with. @@ -966,6 +1100,30 @@ spec: 2) has CAP_SYS_ADMIN Note that this field cannot be set when spec.os.name is windows. type: boolean + appArmorProfile: + description: |- + appArmorProfile is the AppArmor options to use by this container. If set, this profile + overrides the pod's appArmorProfile. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + type: string + type: + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + type: string + required: + - type + type: object capabilities: description: |- The capabilities to add/drop when running containers. @@ -979,6 +1137,7 @@ spec: type type: string type: array + x-kubernetes-list-type: atomic drop: description: Removed capabilities items: @@ -986,6 +1145,7 @@ spec: type type: string type: array + x-kubernetes-list-type: atomic type: object privileged: description: |- @@ -997,7 +1157,7 @@ spec: procMount: description: |- procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for + The default value is Default which uses the container runtime defaults for readonly paths and masked paths. This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. @@ -1079,7 +1239,6 @@ spec: type indicates which kind of seccomp profile will be applied. Valid options are: - Localhost - a profile defined in a file on the node should be used. RuntimeDefault - the container runtime default profile should be used. Unconfined - no profile should be applied. @@ -1131,7 +1290,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -1143,6 +1303,7 @@ spec: items: type: string type: array + x-kubernetes-list-type: atomic type: object failureThreshold: description: |- @@ -1151,8 +1312,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a GRPC - port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. Number @@ -1160,18 +1320,19 @@ spec: format: int32 type: integer service: + default: "" description: |- Service is the name of the service to place in the gRPC HealthCheckRequest (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). - If this is not specified, the default behavior is defined by gRPC. type: string required: - port type: object httpGet: - description: HTTPGet specifies the http request to perform. + description: HTTPGet specifies an HTTP GET request to + perform. properties: host: description: |- @@ -1198,6 +1359,7 @@ spec: - value type: object type: array + x-kubernetes-list-type: atomic path: description: Path to access on the HTTP server. type: string @@ -1237,8 +1399,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a TCP + port. properties: host: description: 'Optional: Host name to connect to, @@ -1339,6 +1501,9 @@ spec: - name type: object type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map volumeMounts: description: |- Pod volumes to mount into the container's filesystem. @@ -1358,6 +1523,8 @@ spec: to container and the other way around. When not set, MountPropagationNone is used. This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). type: string name: description: This must match the Name of a Volume. @@ -1367,6 +1534,25 @@ spec: Mounted read-only if true, read-write otherwise (false or unspecified). Defaults to false. type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string subPath: description: |- Path within the volume from which the container's volume should be mounted. @@ -1384,6 +1570,9 @@ spec: - name type: object type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map workingDir: description: |- Container's working directory. @@ -1528,11 +1717,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1544,6 +1731,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1614,11 +1807,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1630,6 +1821,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1700,11 +1897,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1716,6 +1911,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1786,11 +1987,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1802,6 +2001,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1877,11 +2082,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1893,6 +2096,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1963,11 +2172,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -1979,6 +2186,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2049,11 +2262,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -2065,6 +2276,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2135,11 +2352,9 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry @@ -2151,6 +2366,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2197,9 +2418,9 @@ spec: updatewindow: default: 0 description: |- - (Optional) Period in seconds between yanet resources reconcilation on different nodes. - When the value is non-zero, the operator expects number of seconds between end of resource reconcilation on one node - and resource reconcilation start on another node. + (Optional) Period in seconds between yanet resources reconciliation on different nodes. + When the value is non-zero, the operator expects number of seconds between end of resource reconciliation on one node + and resource reconciliation start on another node. Default: 0 type: integer type: object @@ -2216,7 +2437,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.20.1 name: yanets.yanet.yanet-platform.io spec: group: yanet.yanet-platform.io @@ -2374,6 +2595,64 @@ spec: status: description: YanetStatus defines the observed state of Yanet. properties: + conditions: + description: Conditions represent the latest available observations + of the Yanet's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array pods: additionalProperties: items: diff --git a/deploy/charts/yanet-operator/dashboards/yanet-operator.json b/deploy/charts/yanet-operator/dashboards/yanet-operator.json new file mode 100644 index 0000000..ecff8f7 --- /dev/null +++ b/deploy/charts/yanet-operator/dashboards/yanet-operator.json @@ -0,0 +1,738 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(yanet_reconcile_total{result=\"success\"}[5m])", + "legendFormat": "{{name}} - success", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(yanet_reconcile_total{result=\"error\"}[5m])", + "legendFormat": "{{name}} - error", + "refId": "B" + } + ], + "title": "Yanet Reconciliation Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(yanetconfig_reconcile_total{result=\"success\"}[5m])", + "legendFormat": "{{name}} - success", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(yanetconfig_reconcile_total{result=\"error\"}[5m])", + "legendFormat": "{{name}} - error", + "refId": "B" + } + ], + "title": "YanetConfig Reconciliation Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.50, rate(yanet_reconcile_duration_seconds_bucket[5m]))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, rate(yanet_reconcile_duration_seconds_bucket[5m]))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.99, rate(yanet_reconcile_duration_seconds_bucket[5m]))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "Yanet Reconciliation Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.50, rate(yanetconfig_reconcile_duration_seconds_bucket[5m]))", + "legendFormat": "P50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, rate(yanetconfig_reconcile_duration_seconds_bucket[5m]))", + "legendFormat": "P95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.99, rate(yanetconfig_reconcile_duration_seconds_bucket[5m]))", + "legendFormat": "P99", + "refId": "C" + } + ], + "title": "YanetConfig Reconciliation Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "9.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(yanet_deployments_out_of_sync) by (name, namespace)", + "legendFormat": "{{name}} ({{namespace}})", + "refId": "A" + } + ], + "title": "Deployments Out of Sync", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "yanet_resources_total", + "legendFormat": "Total {{type}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "yanet_resources_ready", + "legendFormat": "Ready {{type}}", + "refId": "B" + } + ], + "title": "Yanet Resources", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(yanet_reconcile_total{result=\"success\"}[5m]) / rate(yanet_reconcile_total[5m])", + "legendFormat": "Yanet {{name}} success rate", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(yanetconfig_reconcile_total{result=\"success\"}[5m]) / rate(yanetconfig_reconcile_total[5m])", + "legendFormat": "YanetConfig {{name}} success rate", + "refId": "B" + } + ], + "title": "Success Rate", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "style": "dark", + "tags": ["yanet", "kubernetes", "operator"], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Prometheus", + "value": "Prometheus" + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Yanet Operator", + "uid": "yanet-operator", + "version": 1, + "weekStart": "" +} diff --git a/deploy/charts/yanet-operator/templates/deployment.yaml b/deploy/charts/yanet-operator/templates/deployment.yaml index 6f454dc..2dfaa72 100644 --- a/deploy/charts/yanet-operator/templates/deployment.yaml +++ b/deploy/charts/yanet-operator/templates/deployment.yaml @@ -1,7 +1,7 @@ apiVersion: apps/v1 kind: Deployment metadata: - namespace: {{ .Values.namespace }} + namespace: {{ .Release.Namespace }} name: {{ include "yanet-operator.fullname" . }} labels: {{- include "yanet-operator.labels" . | nindent 4 }} @@ -37,6 +37,12 @@ spec: - /manager securityContext: {{- toYaml .Values.securityContext | nindent 12 }} + {{- if .Values.webhook.enabled }} + ports: + - containerPort: {{ .Values.webhook.port }} + name: webhook + protocol: TCP + {{- end }} livenessProbe: httpGet: path: /healthz @@ -51,8 +57,20 @@ spec: periodSeconds: 10 image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.webhook.enabled }} + volumeMounts: + - name: webhook-certs + mountPath: /tmp/k8s-webhook-server/serving-certs + readOnly: true + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.webhook.enabled }} + volumes: + - name: webhook-certs + secret: + secretName: {{ include "yanet-operator.fullname" . }}-webhook-certs + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/deploy/charts/yanet-operator/templates/grafana-dashboard.yaml b/deploy/charts/yanet-operator/templates/grafana-dashboard.yaml new file mode 100644 index 0000000..f9517df --- /dev/null +++ b/deploy/charts/yanet-operator/templates/grafana-dashboard.yaml @@ -0,0 +1,19 @@ +{{- if .Values.grafana.dashboards.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "yanet-operator.fullname" . }}-dashboard + namespace: {{ .Values.grafana.dashboards.namespace | default .Release.Namespace }} + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} + {{- with .Values.grafana.dashboards.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.grafana.dashboards.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + yanet-operator.json: |- +{{ .Files.Get "dashboards/yanet-operator.json" | indent 4 }} +{{- end }} diff --git a/deploy/charts/yanet-operator/templates/metrics-service.yaml b/deploy/charts/yanet-operator/templates/metrics-service.yaml new file mode 100644 index 0000000..3d7762e --- /dev/null +++ b/deploy/charts/yanet-operator/templates/metrics-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "yanet-operator.fullname" . }}-metrics + namespace: {{ .Release.Namespace }} + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: metrics + port: 8080 + targetPort: 8080 + protocol: TCP + selector: + {{- include "yanet-operator.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/deploy/charts/yanet-operator/templates/rbac.yaml b/deploy/charts/yanet-operator/templates/rbac.yaml index 5eaee0b..2b1cd6e 100644 --- a/deploy/charts/yanet-operator/templates/rbac.yaml +++ b/deploy/charts/yanet-operator/templates/rbac.yaml @@ -9,7 +9,7 @@ metadata: app.kubernetes.io/name: serviceaccount app.kubernetes.io/part-of: yanet-operator name: controller-manager - namespace: {{ .Values.namespace }} + namespace: {{ .Release.Namespace }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role @@ -22,6 +22,7 @@ metadata: app.kubernetes.io/name: role app.kubernetes.io/part-of: yanet-operator name: leader-election-role + namespace: {{ .Release.Namespace }} rules: - apiGroups: - "" @@ -54,12 +55,31 @@ rules: verbs: - create - patch +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - create + - patch + - update --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + verbs: + - get + - list + - watch + - patch + - update - apiGroups: - apps resources: @@ -232,7 +252,7 @@ roleRef: subjects: - kind: ServiceAccount name: controller-manager - namespace: {{ .Values.namespace }} + namespace: {{ .Release.Namespace }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -252,7 +272,7 @@ roleRef: subjects: - kind: ServiceAccount name: controller-manager - namespace: {{ .Values.namespace }} + namespace: {{ .Release.Namespace }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -272,7 +292,7 @@ roleRef: subjects: - kind: ServiceAccount name: controller-manager - namespace: {{ .Values.namespace }} + namespace: {{ .Release.Namespace }} --- apiVersion: v1 kind: Service @@ -286,7 +306,7 @@ metadata: app.kubernetes.io/part-of: yanet-operator control-plane: controller-manager name: controller-manager-metrics-service - namespace: {{ .Values.namespace }} + namespace: {{ .Release.Namespace }} spec: ports: - name: https diff --git a/deploy/charts/yanet-operator/templates/servicemonitor.yaml b/deploy/charts/yanet-operator/templates/servicemonitor.yaml new file mode 100644 index 0000000..5b49f56 --- /dev/null +++ b/deploy/charts/yanet-operator/templates/servicemonitor.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "yanet-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} + release: {{ .Values.metrics.serviceMonitor.release }} +spec: + endpoints: + - port: metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + jobLabel: {{ include "yanet-operator.fullname" . }} + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + selector: + matchLabels: + {{- include "yanet-operator.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/deploy/charts/yanet-operator/templates/webhook-cert-jobs.yaml b/deploy/charts/yanet-operator/templates/webhook-cert-jobs.yaml new file mode 100644 index 0000000..e6804e1 --- /dev/null +++ b/deploy/charts/yanet-operator/templates/webhook-cert-jobs.yaml @@ -0,0 +1,64 @@ +{{- if and .Values.webhook.enabled (not .Values.webhook.certManager.enabled) }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "yanet-operator.fullname" . }}-webhook-cert-gen + namespace: {{ .Release.Namespace }} + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + template: + metadata: + name: {{ include "yanet-operator.fullname" . }}-webhook-cert-gen + labels: + {{- include "yanet-operator.labels" . | nindent 8 }} + spec: + restartPolicy: OnFailure + serviceAccountName: {{ include "yanet-operator.serviceAccountName" . }} + containers: + - name: cert-generator + image: "{{ .Values.webhook.certGen.image.repository }}:{{ .Values.webhook.certGen.image.tag }}" + imagePullPolicy: {{ .Values.webhook.certGen.image.pullPolicy }} + args: + - create + - --host={{ include "yanet-operator.fullname" . }}-webhook-service,{{ include "yanet-operator.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc + - --namespace={{ .Release.Namespace }} + - --cert-name=tls.crt + - --key-name=tls.key + - --secret-name={{ include "yanet-operator.fullname" . }}-webhook-certs +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "yanet-operator.fullname" . }}-webhook-update-ca + namespace: {{ .Release.Namespace }} + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + template: + metadata: + name: {{ include "yanet-operator.fullname" . }}-webhook-update-ca + labels: + {{- include "yanet-operator.labels" . | nindent 8 }} + spec: + restartPolicy: OnFailure + serviceAccountName: {{ include "yanet-operator.serviceAccountName" . }} + containers: + - name: ca-updater + image: "{{ .Values.webhook.certGen.image.repository }}:{{ .Values.webhook.certGen.image.tag }}" + imagePullPolicy: {{ .Values.webhook.certGen.image.pullPolicy }} + args: + - patch + - --namespace={{ .Release.Namespace }} + - --secret-name={{ include "yanet-operator.fullname" . }}-webhook-certs + - --webhook-name={{ include "yanet-operator.fullname" . }}-validating-webhook-configuration + - --patch-validating=true + - --patch-mutating=false +{{- end }} diff --git a/deploy/charts/yanet-operator/templates/webhook-configuration.yaml b/deploy/charts/yanet-operator/templates/webhook-configuration.yaml new file mode 100644 index 0000000..fc00f65 --- /dev/null +++ b/deploy/charts/yanet-operator/templates/webhook-configuration.yaml @@ -0,0 +1,49 @@ +{{- if .Values.webhook.enabled }} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: {{ include "yanet-operator.fullname" . }}-validating-webhook-configuration + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: {{ include "yanet-operator.fullname" . }}-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-yanet-yanet-platform-io-v1alpha1-yanet + failurePolicy: Fail + name: validate.yanet.yanet-platform.io + rules: + - apiGroups: + - yanet.yanet-platform.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - yanets + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: {{ include "yanet-operator.fullname" . }}-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-yanet-yanet-platform-io-v1alpha1-yanetconfig + failurePolicy: Fail + name: validate.yanetconfig.yanet-platform.io + rules: + - apiGroups: + - yanet.yanet-platform.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - yanetconfigs + sideEffects: None +{{- end }} diff --git a/deploy/charts/yanet-operator/templates/webhook-service.yaml b/deploy/charts/yanet-operator/templates/webhook-service.yaml new file mode 100644 index 0000000..fafc2a1 --- /dev/null +++ b/deploy/charts/yanet-operator/templates/webhook-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.webhook.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "yanet-operator.fullname" . }}-webhook-service + namespace: {{ .Release.Namespace }} + labels: + {{- include "yanet-operator.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: 443 + targetPort: {{ .Values.webhook.port }} + protocol: TCP + name: webhook + selector: + {{- include "yanet-operator.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/deploy/charts/yanet-operator/templates/yanetconfig.yaml b/deploy/charts/yanet-operator/templates/yanetconfig.yaml index d5f414d..d0effa0 100644 --- a/deploy/charts/yanet-operator/templates/yanetconfig.yaml +++ b/deploy/charts/yanet-operator/templates/yanetconfig.yaml @@ -3,7 +3,7 @@ apiVersion: yanet.yanet-platform.io/v1alpha1 kind: YanetConfig metadata: name: config - namespace: {{ .Values.namespace }} + namespace: {{ $.Release.Namespace }} spec: {{- with .spec }} {{- toYaml . | nindent 2 }} diff --git a/deploy/charts/yanet-operator/values.yaml b/deploy/charts/yanet-operator/values.yaml index d614f31..2dc395a 100644 --- a/deploy/charts/yanet-operator/values.yaml +++ b/deploy/charts/yanet-operator/values.yaml @@ -1,9 +1,9 @@ replicaCount: 2 image: - repository: yanet-platform/yanet-operator + repository: yanetplatform/yanet-operator pullPolicy: IfNotPresent - tag: "latest" + # tag: "" # If not set, defaults to .Chart.AppVersion from Chart.yaml imagePullSecrets: [] nameOverride: "" @@ -33,6 +33,40 @@ nodeSelector: {} affinity: {} +# Webhook configuration +webhook: + enabled: true + port: 9443 + certManager: + enabled: false + # Certificate generation using kube-webhook-certgen + certGen: + image: + repository: registry.k8s.io/ingress-nginx/kube-webhook-certgen + tag: v1.5.2 + pullPolicy: IfNotPresent + +# Metrics configuration +metrics: + enabled: true + serviceMonitor: + enabled: true + interval: 30s + release: kube-prometheus-stack + +# Grafana dashboards configuration +grafana: + dashboards: + enabled: true + # Namespace where the dashboard ConfigMap will be created + namespace: kube-mon + # Labels to add to the ConfigMap + # These labels are used by Grafana sidecar to discover dashboards + labels: + grafana_dashboard: "1" + # Annotations to add to the ConfigMap + annotations: {} + yanetconfig: spec: autodiscovery: diff --git a/internal/controller/metrics.go b/internal/controller/metrics.go new file mode 100644 index 0000000..e143e15 --- /dev/null +++ b/internal/controller/metrics.go @@ -0,0 +1,102 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + // yanetReconcileTotal counts total number of reconciliations per Yanet resource + yanetReconcileTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "yanet_reconcile_total", + Help: "Total number of reconciliations per Yanet resource", + }, + []string{"name", "namespace", "result"}, + ) + + // yanetReconcileDuration tracks the duration of reconciliations + yanetReconcileDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "yanet_reconcile_duration_seconds", + Help: "Duration of Yanet reconciliations in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"name", "namespace"}, + ) + + // yanetDeploymentsOutOfSync tracks number of deployments that are out of sync + yanetDeploymentsOutOfSync = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "yanet_deployments_out_of_sync", + Help: "Number of deployments that are out of sync per Yanet resource", + }, + []string{"name", "namespace"}, + ) + + // yanetConfigReconcileTotal counts total number of reconciliations per YanetConfig resource + yanetConfigReconcileTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "yanetconfig_reconcile_total", + Help: "Total number of reconciliations per YanetConfig resource", + }, + []string{"name", "namespace", "result"}, + ) + + // yanetConfigReconcileDuration tracks the duration of YanetConfig reconciliations + yanetConfigReconcileDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "yanetconfig_reconcile_duration_seconds", + Help: "Duration of YanetConfig reconciliations in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"name", "namespace"}, + ) + + // yanetResourcesTotal tracks total number of Yanet resources + yanetResourcesTotal = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "yanet_resources_total", + Help: "Total number of Yanet resources", + }, + []string{"type"}, + ) + + // yanetResourcesReady tracks number of ready Yanet resources + yanetResourcesReady = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "yanet_resources_ready", + Help: "Number of ready Yanet resources", + }, + []string{"type"}, + ) +) + +func init() { + // Register custom metrics with the global prometheus registry + metrics.Registry.MustRegister( + yanetReconcileTotal, + yanetReconcileDuration, + yanetDeploymentsOutOfSync, + yanetConfigReconcileTotal, + yanetConfigReconcileDuration, + yanetResourcesTotal, + yanetResourcesReady, + ) +} diff --git a/internal/controller/node_deletion.go b/internal/controller/node_deletion.go new file mode 100644 index 0000000..a0e9215 --- /dev/null +++ b/internal/controller/node_deletion.go @@ -0,0 +1,97 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" +) + +// handleNodeDeletion handles cleanup when a worker node is deleted +// It finds and deletes the corresponding Yanet CRD +func (r *YanetReconciler) handleNodeDeletion(ctx context.Context, nodeName string) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // List all Yanet resources + u := &unstructured.UnstructuredList{} + u.SetGroupVersionKind(schema.GroupVersionKind{ + Kind: "Yanet", + Version: "v1alpha1", + Group: "yanet.yanet-platform.io", + }) + + err := r.Client.List(ctx, u) + if err != nil { + logger.Error(err, "Failed to list Yanet resources for node deletion cleanup") + return ctrl.Result{}, err + } + + // Find Yanet resource for this node + for _, obj := range u.Items { + yanet := &yanetv1alpha1.Yanet{} + err = r.Client.Get(ctx, client.ObjectKey{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + }, yanet) + + if err != nil { + if errors.IsNotFound(err) { + continue + } + logger.Error(err, "Failed to get Yanet resource") + continue + } + + // Check if this Yanet is for the deleted node + if yanet.Spec.NodeName == nodeName { + logger.Info("Found Yanet for deleted node, deleting it", + "yanet", yanet.Name, + "namespace", yanet.Namespace, + "node", nodeName) + + r.Recorder.Event(yanet, v1.EventTypeNormal, "NodeDeleted", + fmt.Sprintf("Worker node %s deleted, cleaning up Yanet resource", nodeName)) + + err = r.Client.Delete(ctx, yanet) + if err != nil && !errors.IsNotFound(err) { + logger.Error(err, "Failed to delete Yanet resource for deleted node") + r.Recorder.Event(yanet, v1.EventTypeWarning, "CleanupFailed", + fmt.Sprintf("Failed to delete Yanet for deleted node: %v", err)) + return ctrl.Result{}, err + } + + logger.Info("Successfully deleted Yanet for deleted node", + "yanet", yanet.Name, + "namespace", yanet.Namespace, + "node", nodeName) + return ctrl.Result{}, nil + } + } + + logger.Info("No Yanet resource found for deleted node", "node", nodeName) + return ctrl.Result{}, nil +} diff --git a/internal/controller/node_deletion_test.go b/internal/controller/node_deletion_test.go new file mode 100644 index 0000000..f1ab3c1 --- /dev/null +++ b/internal/controller/node_deletion_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// TestHandleNodeDeletion tests the handleNodeDeletion method +func TestHandleNodeDeletion(t *testing.T) { + scheme := runtime.NewScheme() + _ = yanetv1alpha1.AddToScheme(scheme) + _ = v1.AddToScheme(scheme) + + tests := []struct { + name string + nodeName string + existingYanets []client.Object + expectDeletion bool + expectError bool + }{ + { + name: "no yanet resources", + nodeName: "node1", + existingYanets: []client.Object{}, + expectDeletion: false, + expectError: false, + }, + { + name: "yanet found for deleted node", + nodeName: "node1", + existingYanets: []client.Object{ + &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "yanet-node1", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "node1", + }, + }, + }, + expectDeletion: true, + expectError: false, + }, + { + name: "yanet for different node", + nodeName: "node1", + existingYanets: []client.Object{ + &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "yanet-node2", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "node2", + }, + }, + }, + expectDeletion: false, + expectError: false, + }, + { + name: "multiple yanets, only one matches", + nodeName: "node1", + existingYanets: []client.Object{ + &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "yanet-node1", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "node1", + }, + }, + &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "yanet-node2", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "node2", + }, + }, + }, + expectDeletion: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create fake client with existing Yanet resources + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.existingYanets...). + Build() + + // Create reconciler + r := &YanetReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(10), + } + + // Call handleNodeDeletion + ctx := context.Background() + _, err := r.handleNodeDeletion(ctx, tt.nodeName) + + // Check error + if (err != nil) != tt.expectError { + t.Errorf("handleNodeDeletion() error = %v, expectError %v", err, tt.expectError) + return + } + + // If deletion expected, verify Yanet was deleted + if tt.expectDeletion { + yanetList := &yanetv1alpha1.YanetList{} + err = fakeClient.List(ctx, yanetList) + if err != nil { + t.Fatalf("Failed to list Yanets: %v", err) + } + + // Check that Yanet for deleted node is gone + for _, yanet := range yanetList.Items { + if yanet.Spec.NodeName == tt.nodeName { + t.Errorf("Yanet for node %s still exists after handleNodeDeletion", tt.nodeName) + } + } + } + }) + } +} diff --git a/internal/controller/node_reconciler.go b/internal/controller/node_reconciler.go index 8364797..dd900ba 100644 --- a/internal/controller/node_reconciler.go +++ b/internal/controller/node_reconciler.go @@ -39,12 +39,12 @@ func (r *YanetReconciler) reconcilerNode(ctx context.Context, config *yanetv1alp logger := log.FromContext(ctx) for label := range node.Labels { if label == "node-role.kubernetes.io/control-plane" { - logger.Info(fmt.Sprintf("AutoDiscovery: found new Node with name: %s but it is controlplane, skip it.", node.Name)) + logger.Info("AutoDiscovery: found control-plane node, skipping", "node", node.Name) return ctrl.Result{}, nil } } - logger.Info(fmt.Sprintf("AutoDiscovery: found new Node with name: %s", node.Name)) - logger.Info(fmt.Sprintf("AutoDiscovery: try find existing Yanet object for Node: %s", node.Name)) + logger.Info("AutoDiscovery: found new worker node", "node", node.Name) + logger.Info("AutoDiscovery: looking for existing Yanet object", "node", node.Name) yanet := &yanetv1alpha1.Yanet{} u := &unstructured.UnstructuredList{} @@ -71,23 +71,20 @@ func (r *YanetReconciler) reconcilerNode(ctx context.Context, config *yanetv1alp err = r.Client.Get(ctx, nn, yanet) if err != nil { if errors.IsNotFound(err) { - logger.Info(fmt.Sprintf( - `AutoDiscovery: Yanet resource not found in cluster for NamespacedName: %s. - Ignoring since object must be deleted`, - nn, - )) + logger.Info("AutoDiscovery: Yanet resource not found, ignoring since object must be deleted", + "namespacedName", nn) } else { logger.Error(err, fmt.Sprintf("AutoDiscovery: failed to get Yanet object with NamespacedName: %s.", nn)) return ctrl.Result{}, err } } if yanet.Spec.NodeName == node.Name { - logger.Info(fmt.Sprintf("AutoDiscovery: Yanet object for NamespacedName: %s already exist.", nn)) + logger.Info("AutoDiscovery: Yanet object already exists", "namespacedName", nn, "node", node.Name) return ctrl.Result{}, nil } } - logger.Info(fmt.Sprintf("AutoDiscovery: try to create Yanet object for Node: %s", node.Name)) + logger.Info("AutoDiscovery: creating new Yanet object", "node", node.Name) dataplane := &yanetv1alpha1.Dep{ Image: config.AutoDiscovery.Images.Dataplane, } @@ -103,12 +100,12 @@ func (r *YanetReconciler) reconcilerNode(ctx context.Context, config *yanetv1alp version, err := helpers.HttpGet(fmt.Sprintf("%s/%s", config.AutoDiscovery.ConfigsUri, node.Name)) if err != nil { - logger.Error(err, fmt.Sprintf("AutoDiscovery: can not get version for Node: %s, use latest", node.Name)) + logger.Error(err, "AutoDiscovery: cannot get version, using latest", "node", node.Name) version = "latest" } t, err := helpers.HttpGet(fmt.Sprintf("%s/%s", config.AutoDiscovery.TypeUri, node.Name)) if err != nil { - logger.Error(err, fmt.Sprintf("AutoDiscovery: can not get type for Node: %s, use release", node.Name)) + logger.Error(err, "AutoDiscovery: cannot get type, using release", "node", node.Name) t = "release" } newyanet := &yanetv1alpha1.Yanet{ @@ -127,10 +124,10 @@ func (r *YanetReconciler) reconcilerNode(ctx context.Context, config *yanetv1alp Bird: *bird, }, } - logger.Info(fmt.Sprintf("AutoDiscovery: create new Yanet object for Node: %s", node.Name)) + logger.Info("AutoDiscovery: creating new Yanet object", "node", node.Name, "version", version, "type", t) err = r.Client.Create(ctx, newyanet) if err != nil { - logger.Error(err, fmt.Sprintf("AutoDiscovery: can not create new Yanet object for Node: %s", node.Name)) + logger.Error(err, "AutoDiscovery: failed to create new Yanet object", "node", node.Name) return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 6ac95ee..cbfe2b8 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -17,6 +17,7 @@ limitations under the License. package controller import ( + "context" "path/filepath" "testing" @@ -25,6 +26,7 @@ import ( "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" @@ -40,6 +42,10 @@ import ( var cfg *rest.Config var k8sClient client.Client var testEnv *envtest.Environment +var reconciler *YanetReconciler +var ctx context.Context +var cancel context.CancelFunc +var globalConfig *yanetv1alpha1.MutexYanetConfigSpec func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -50,6 +56,8 @@ func TestControllers(t *testing.T) { var _ = BeforeSuite(func() { 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")}, @@ -71,9 +79,50 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) + // Create shared GlobalConfig instance + globalConfig = &yanetv1alpha1.MutexYanetConfigSpec{} + + // Start manager + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + }) + Expect(err).ToNot(HaveOccurred()) + + // Setup YanetReconciler with shared GlobalConfig + err = (&YanetReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("yanet-controller"), //nolint:staticcheck // SA1019: old API still works + GlobalConfig: globalConfig, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + // Setup YanetConfigReconciler with SAME GlobalConfig + err = (&YanetConfigReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + GlobalConfig: globalConfig, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() + + // Initialize reconciler for direct method calls in tests + reconciler = &YanetReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + GlobalConfig: globalConfig, + } }) var _ = AfterSuite(func() { + By("canceling the context") + cancel() + By("tearing down the test environment") err := testEnv.Stop() Expect(err).NotTo(HaveOccurred()) diff --git a/internal/controller/yanet_conditions.go b/internal/controller/yanet_conditions.go new file mode 100644 index 0000000..e035e25 --- /dev/null +++ b/internal/controller/yanet_conditions.go @@ -0,0 +1,116 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" +) + +const ( + // ConditionTypeReady indicates whether all deployments are ready + ConditionTypeReady = "Ready" + // ConditionTypeSynced indicates whether deployments are in sync with spec + ConditionTypeSynced = "Synced" + // ConditionTypeProgressing indicates whether deployments are being updated + ConditionTypeProgressing = "Progressing" +) + +// computeConditions calculates status conditions based on sync state and pods +func (r *YanetReconciler) computeConditions( + yanet *yanetv1alpha1.Yanet, + sync yanetv1alpha1.Sync, + pods map[v1.PodPhase][]string, +) []metav1.Condition { + now := metav1.Now() + conditions := []metav1.Condition{} + + // Condition: Synced + syncedCondition := metav1.Condition{ + Type: ConditionTypeSynced, + Status: metav1.ConditionTrue, + ObservedGeneration: yanet.Generation, + LastTransitionTime: now, + Reason: "AllDeploymentsSynced", + Message: "All deployments are in sync with spec", + } + + if len(sync.OutOfSync) > 0 { + syncedCondition.Status = metav1.ConditionFalse + syncedCondition.Reason = "DeploymentsOutOfSync" + syncedCondition.Message = fmt.Sprintf("Deployments out of sync: %v (AutoSync disabled)", sync.OutOfSync) + } else if len(sync.Error) > 0 { + syncedCondition.Status = metav1.ConditionFalse + syncedCondition.Reason = "SyncError" + syncedCondition.Message = fmt.Sprintf("Errors syncing deployments: %v", sync.Error) + } + + conditions = append(conditions, syncedCondition) + + // Condition: Progressing + progressingCondition := metav1.Condition{ + Type: ConditionTypeProgressing, + Status: metav1.ConditionFalse, + ObservedGeneration: yanet.Generation, + LastTransitionTime: now, + Reason: "NoUpdatesInProgress", + Message: "No updates in progress", + } + + if len(sync.SyncWaiting) > 0 { + progressingCondition.Status = metav1.ConditionTrue + progressingCondition.Reason = "WaitingForUpdateWindow" + progressingCondition.Message = fmt.Sprintf("Waiting for UpdateWindow: %v", sync.SyncWaiting) + } + + conditions = append(conditions, progressingCondition) + + // Condition: Ready + readyCondition := metav1.Condition{ + Type: ConditionTypeReady, + Status: metav1.ConditionTrue, + ObservedGeneration: yanet.Generation, + LastTransitionTime: now, + Reason: "AllPodsRunning", + Message: "All pods are running", + } + + runningPods := len(pods[v1.PodRunning]) + totalEnabled := len(sync.Synced) + len(sync.Disabled) + + if runningPods == 0 && totalEnabled > 0 { + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = "NoPodsRunning" + readyCondition.Message = "No pods are running" + } else if len(pods[v1.PodPending]) > 0 { + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = "PodsNotReady" + readyCondition.Message = fmt.Sprintf("%d pods pending", len(pods[v1.PodPending])) + } else if len(pods[v1.PodFailed]) > 0 { + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = "PodsFailed" + readyCondition.Message = fmt.Sprintf("%d pods failed", len(pods[v1.PodFailed])) + } + + conditions = append(conditions, readyCondition) + + return conditions +} diff --git a/internal/controller/yanet_conditions_test.go b/internal/controller/yanet_conditions_test.go new file mode 100644 index 0000000..826138d --- /dev/null +++ b/internal/controller/yanet_conditions_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestComputeConditions tests the computeConditions method +func TestComputeConditions(t *testing.T) { + tests := []struct { + name string + sync yanetv1alpha1.Sync + pods map[v1.PodPhase][]string + expectedSynced metav1.ConditionStatus + expectedProgressing metav1.ConditionStatus + expectedReady metav1.ConditionStatus + expectedSyncedReason string + expectedReadyReason string + }{ + { + name: "all synced and running", + sync: yanetv1alpha1.Sync{ + Synced: []string{"dataplane", "controlplane"}, + OutOfSync: []string{}, + SyncWaiting: []string{}, + Error: []string{}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1", "pod2"}, + }, + expectedSynced: metav1.ConditionTrue, + expectedProgressing: metav1.ConditionFalse, + expectedReady: metav1.ConditionTrue, + expectedSyncedReason: "AllDeploymentsSynced", + expectedReadyReason: "AllPodsRunning", + }, + { + name: "out of sync deployments", + sync: yanetv1alpha1.Sync{ + Synced: []string{"dataplane"}, + OutOfSync: []string{"controlplane"}, + SyncWaiting: []string{}, + Error: []string{}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1"}, + }, + expectedSynced: metav1.ConditionFalse, + expectedProgressing: metav1.ConditionFalse, + expectedReady: metav1.ConditionTrue, + expectedSyncedReason: "DeploymentsOutOfSync", + expectedReadyReason: "AllPodsRunning", + }, + { + name: "sync errors", + sync: yanetv1alpha1.Sync{ + Synced: []string{}, + OutOfSync: []string{}, + SyncWaiting: []string{}, + Error: []string{"dataplane: failed to create"}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{}, + expectedSynced: metav1.ConditionFalse, + expectedProgressing: metav1.ConditionFalse, + expectedReady: metav1.ConditionTrue, + expectedSyncedReason: "SyncError", + expectedReadyReason: "AllPodsRunning", + }, + { + name: "waiting for update window", + sync: yanetv1alpha1.Sync{ + Synced: []string{"dataplane"}, + OutOfSync: []string{}, + SyncWaiting: []string{"controlplane"}, + Error: []string{}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1"}, + }, + expectedSynced: metav1.ConditionTrue, + expectedProgressing: metav1.ConditionTrue, + expectedReady: metav1.ConditionTrue, + expectedSyncedReason: "AllDeploymentsSynced", + expectedReadyReason: "AllPodsRunning", + }, + { + name: "pods pending with running pods", + sync: yanetv1alpha1.Sync{ + Synced: []string{"dataplane"}, + OutOfSync: []string{}, + SyncWaiting: []string{}, + Error: []string{}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1"}, + v1.PodPending: {"pod2"}, + }, + expectedSynced: metav1.ConditionTrue, + expectedProgressing: metav1.ConditionFalse, + expectedReady: metav1.ConditionFalse, + expectedSyncedReason: "AllDeploymentsSynced", + expectedReadyReason: "PodsNotReady", + }, + { + name: "pods failed with running pods", + sync: yanetv1alpha1.Sync{ + Synced: []string{"dataplane"}, + OutOfSync: []string{}, + SyncWaiting: []string{}, + Error: []string{}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1"}, + v1.PodFailed: {"pod2"}, + }, + expectedSynced: metav1.ConditionTrue, + expectedProgressing: metav1.ConditionFalse, + expectedReady: metav1.ConditionFalse, + expectedSyncedReason: "AllDeploymentsSynced", + expectedReadyReason: "PodsFailed", + }, + { + name: "no pods running but deployments enabled", + sync: yanetv1alpha1.Sync{ + Synced: []string{"dataplane"}, + OutOfSync: []string{}, + SyncWaiting: []string{}, + Error: []string{}, + Disabled: []string{}, + }, + pods: map[v1.PodPhase][]string{}, + expectedSynced: metav1.ConditionTrue, + expectedProgressing: metav1.ConditionFalse, + expectedReady: metav1.ConditionFalse, + expectedSyncedReason: "AllDeploymentsSynced", + expectedReadyReason: "NoPodsRunning", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &YanetReconciler{} + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + } + + conditions := r.computeConditions(yanet, tt.sync, tt.pods) + + // Should have 3 conditions: Synced, Progressing, Ready + if len(conditions) != 3 { + t.Fatalf("Expected 3 conditions, got %d", len(conditions)) + } + + // Find each condition + var syncedCond, progressingCond, readyCond *metav1.Condition + for i := range conditions { + switch conditions[i].Type { + case ConditionTypeSynced: + syncedCond = &conditions[i] + case ConditionTypeProgressing: + progressingCond = &conditions[i] + case ConditionTypeReady: + readyCond = &conditions[i] + } + } + + // Verify Synced condition + if syncedCond == nil { + t.Fatal("Synced condition not found") + } + if syncedCond.Status != tt.expectedSynced { + t.Errorf("Synced condition status = %v, want %v", syncedCond.Status, tt.expectedSynced) + } + if syncedCond.Reason != tt.expectedSyncedReason { + t.Errorf("Synced condition reason = %v, want %v", syncedCond.Reason, tt.expectedSyncedReason) + } + + // Verify Progressing condition + if progressingCond == nil { + t.Fatal("Progressing condition not found") + } + if progressingCond.Status != tt.expectedProgressing { + t.Errorf("Progressing condition status = %v, want %v", progressingCond.Status, tt.expectedProgressing) + } + + // Verify Ready condition + if readyCond == nil { + t.Fatal("Ready condition not found") + } + if readyCond.Status != tt.expectedReady { + t.Errorf("Ready condition status = %v, want %v", readyCond.Status, tt.expectedReady) + } + if readyCond.Reason != tt.expectedReadyReason { + t.Errorf("Ready condition reason = %v, want %v", readyCond.Reason, tt.expectedReadyReason) + } + }) + } +} diff --git a/internal/controller/yanet_controller.go b/internal/controller/yanet_controller.go index c1ce124..7058d8b 100644 --- a/internal/controller/yanet_controller.go +++ b/internal/controller/yanet_controller.go @@ -18,7 +18,6 @@ package controller import ( "context" - "fmt" "sync" "time" @@ -26,6 +25,7 @@ import ( v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -38,6 +38,7 @@ import ( type YanetReconciler struct { client.Client Scheme *runtime.Scheme + Recorder record.EventRecorder GlobalConfig *yanetv1alpha1.MutexYanetConfigSpec lock sync.Mutex lastUpdateTS time.Time @@ -64,8 +65,9 @@ type YanetReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile func (r *YanetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + startTime := time.Now() logger := log.FromContext(ctx) - logger.Info(fmt.Sprintf("Reconcile loop called for NamespacedName: %s", req.NamespacedName)) + logger.Info("Reconcile loop called", "namespacedName", req.NamespacedName) // Deep copy config under lock to avoid data race on nested slices/maps. r.GlobalConfig.Lock.Lock() @@ -83,33 +85,44 @@ func (r *YanetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl if !errors.IsNotFound(err) { // Error reading the object - requeue the request. logger.Error(err, "Error while getting Yanet object") + yanetReconcileTotal.WithLabelValues(req.Name, req.Namespace, "error").Inc() return ctrl.Result{}, err } } else { - logger.Info(fmt.Sprintf("Reconcile: successfully found Yanet object for NamespacedName: %s", req.NamespacedName)) - return r.reconcilerYanet(ctx, yanet, config) + logger.Info("Successfully found Yanet object", "namespacedName", req.NamespacedName) + result, reconcileErr := r.reconcilerYanet(ctx, yanet, config) + + // Record metrics + duration := time.Since(startTime).Seconds() + yanetReconcileDuration.WithLabelValues(req.Name, req.Namespace).Observe(duration) + + if reconcileErr != nil { + yanetReconcileTotal.WithLabelValues(req.Name, req.Namespace, "error").Inc() + } else { + yanetReconcileTotal.WithLabelValues(req.Name, req.Namespace, "success").Inc() + } + + return result, reconcileErr } - // Create Yanet CRD for new worker node by auto. - // Use GlobalConfig.AutoDiscovery from YanetConfig CRD for autodiscovery. + // Handle Node events for AutoDiscovery and cleanup node := &v1.Node{} err = r.Client.Get(ctx, req.NamespacedName, node) if err != nil { if errors.IsNotFound(err) { - logger.Info(fmt.Sprintf( - `Reconcile: Node resource not found in cluster for NamespacedName: %s. - Ignoring since object must be deleted`, - req.NamespacedName, - )) + // Node deleted - find and delete corresponding Yanet CRD + logger.Info("Node deleted, looking for corresponding Yanet CRD", "node", req.NamespacedName.Name) + return r.handleNodeDeletion(ctx, req.NamespacedName.Name) } else { logger.Error(err, "Failed to get Node object") return ctrl.Result{}, err } - } else { - logger.Info(fmt.Sprintf("Reconcile: successfully found Node object for NamespacedName: %s", req.NamespacedName)) - if config.AutoDiscovery.Enable { - return r.reconcilerNode(ctx, &config, node) - } + } + + // Node exists - handle AutoDiscovery if enabled + logger.Info("Successfully found Node object", "namespacedName", req.NamespacedName) + if config.AutoDiscovery.Enable { + return r.reconcilerNode(ctx, &config, node) } return ctrl.Result{}, nil diff --git a/internal/controller/yanet_controller_integration_test.go b/internal/controller/yanet_controller_integration_test.go new file mode 100644 index 0000000..cda346e --- /dev/null +++ b/internal/controller/yanet_controller_integration_test.go @@ -0,0 +1,459 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var _ = Describe("YanetReconciler Integration Tests", func() { + const ( + timeout = time.Second * 60 // Increased from 30s to 60s for slower systems + interval = time.Millisecond * 250 + ) + + Context("When reconciling a Yanet resource with type=release", func() { + const ( + yanetName = "test-node-1" + yanetNamespace = "default" + nodeName = "test-node-1" + ) + + ctx := context.Background() + yanetConfigName := types.NamespacedName{Name: "test-config", Namespace: yanetNamespace} + yanetLookupKey := types.NamespacedName{Name: yanetName, Namespace: yanetNamespace} + + BeforeEach(func() { + By("Creating a test Node") + node := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + "hugepages-1Gi": resource.MustParse("45Gi"), + }, + }, + } + Expect(k8sClient.Create(ctx, node)).Should(Succeed()) + + By("Creating a YanetConfig") + config := &yanetv1alpha1.YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: yanetConfigName.Name, + Namespace: yanetConfigName.Namespace, + }, + Spec: yanetv1alpha1.YanetConfigSpec{ + Stop: false, + UpdateWindow: 60, + EnabledOpts: yanetv1alpha1.EnabledOpts{ + Release: yanetv1alpha1.DepOpts{ + Dataplain: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + HostIpc: true, + Privileged: true, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "memory": resource.MustParse("32Gi"), + }, + }, + }, + Controlplane: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + HostIpc: true, + Privileged: false, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("6"), + "memory": resource.MustParse("128Gi"), + }, + Requests: v1.ResourceList{ + "cpu": resource.MustParse("1"), + "memory": resource.MustParse("16Gi"), + }, + }, + }, + Announcer: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + Privileged: false, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("4"), + "memory": resource.MustParse("32Gi"), + }, + Requests: v1.ResourceList{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("4Gi"), + }, + }, + }, + Bird: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + Privileged: false, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("6"), + "memory": resource.MustParse("64Gi"), + }, + Requests: v1.ResourceList{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("4Gi"), + }, + }, + }, + }, + }, + AdditionalOpts: yanetv1alpha1.AdditionalOpts{ + Annotations: []yanetv1alpha1.NamedAnnotations{ + { + Name: "checkpointer", + Annotations: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + "checkpointer.ydb.tech/manual-recovery": "true", + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, config)).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up resources") + // Delete Yanet + yanet := &yanetv1alpha1.Yanet{} + err := k8sClient.Get(ctx, yanetLookupKey, yanet) + if err == nil { + Expect(k8sClient.Delete(ctx, yanet)).Should(Succeed()) + } + + // Delete YanetConfig + config := &yanetv1alpha1.YanetConfig{} + err = k8sClient.Get(ctx, yanetConfigName, config) + if err == nil { + Expect(k8sClient.Delete(ctx, config)).Should(Succeed()) + } + + // Delete Node + node := &v1.Node{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node) + if err == nil { + Expect(k8sClient.Delete(ctx, node)).Should(Succeed()) + } + }) + + It("Should create all 4 deployments with correct configuration", func() { + By("Creating a Yanet resource") + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: yanetName, + Namespace: yanetNamespace, + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: nodeName, + Type: "release", + AutoSync: true, + Tag: "1.0.0", + Registry: "docker.io/test", + Dataplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-dataplane", + }, + Controlplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-controlplane", + }, + Announcer: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-announcer", + }, + Bird: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-bird", + Tag: "2.0.12", + }, + }, + } + Expect(k8sClient.Create(ctx, yanet)).Should(Succeed()) + + By("Checking that all 4 deployments are created") + Eventually(func() int { + depList := &appsv1.DeploymentList{} + err := k8sClient.List(ctx, depList, client.InNamespace(yanetNamespace)) + if err != nil { + return 0 + } + return len(depList.Items) + }, timeout, interval).Should(Equal(4)) + + By("Verifying dataplane deployment") + dataplaneKey := types.NamespacedName{ + Name: "dataplane-" + nodeName, + Namespace: yanetNamespace, + } + dataplaneDep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, dataplaneKey, dataplaneDep) + }, timeout, interval).Should(Succeed()) + + Expect(dataplaneDep.Spec.Template.Spec.Containers).Should(HaveLen(1)) + Expect(dataplaneDep.Spec.Template.Spec.Containers[0].Name).Should(Equal("dataplane")) + Expect(dataplaneDep.Spec.Template.Spec.Containers[0].Image).Should(Equal("docker.io/test/yanet-dataplane:1.0.0")) + Expect(dataplaneDep.Spec.Template.Spec.HostIPC).Should(BeTrue()) + Expect(*dataplaneDep.Spec.Template.Spec.Containers[0].SecurityContext.Privileged).Should(BeTrue()) + Expect(dataplaneDep.Spec.Template.Spec.Containers[0].Resources.Limits["hugepages-1Gi"]).Should(Equal(resource.MustParse("45Gi"))) + + By("Verifying controlplane deployment") + controlplaneKey := types.NamespacedName{ + Name: "controlplane-" + nodeName, + Namespace: yanetNamespace, + } + controlplaneDep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, controlplaneKey, controlplaneDep) + }, timeout, interval).Should(Succeed()) + + Expect(controlplaneDep.Spec.Template.Spec.Containers).Should(HaveLen(1)) + Expect(controlplaneDep.Spec.Template.Spec.Containers[0].Name).Should(Equal("controlplane")) + Expect(controlplaneDep.Spec.Template.Spec.HostIPC).Should(BeTrue()) + Expect(*controlplaneDep.Spec.Template.Spec.Containers[0].SecurityContext.Privileged).Should(BeFalse()) + + By("Verifying bird deployment with custom tag") + birdKey := types.NamespacedName{ + Name: "bird-" + nodeName, + Namespace: yanetNamespace, + } + birdDep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, birdKey, birdDep) + }, timeout, interval).Should(Succeed()) + + Expect(birdDep.Spec.Template.Spec.Containers[0].Image).Should(Equal("docker.io/test/yanet-bird:2.0.12")) + + By("Verifying announcer deployment") + announcerKey := types.NamespacedName{ + Name: "announcer-" + nodeName, + Namespace: yanetNamespace, + } + announcerDep := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, announcerKey, announcerDep) + }, timeout, interval).Should(Succeed()) + + Expect(announcerDep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) + Expect(announcerDep.Spec.Template.Spec.InitContainers[0].Name).Should(Equal("wait-bird")) + + By("Checking Yanet status is updated") + Eventually(func() bool { + updatedYanet := &yanetv1alpha1.Yanet{} + err := k8sClient.Get(ctx, yanetLookupKey, updatedYanet) + if err != nil { + return false + } + return len(updatedYanet.Status.Sync.Synced) == 4 + }, timeout, interval).Should(BeTrue()) + }) + + It("Should respect AutoSync=false and not create deployments", func() { + By("Creating a Yanet resource with AutoSync=false") + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: yanetName + "-nosync", + Namespace: yanetNamespace, + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: nodeName, + Type: "release", + AutoSync: false, // Disabled + Dataplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-dataplane", + }, + }, + } + Expect(k8sClient.Create(ctx, yanet)).Should(Succeed()) + + By("Verifying no deployments are created (AutoSync=false)") + Consistently(func() int { + depList := &appsv1.DeploymentList{} + err := k8sClient.List(ctx, depList, + client.InNamespace(yanetNamespace), + client.MatchingLabels{"topology-location-host": nodeName}) + if err != nil { + return -1 + } + return len(depList.Items) + }, time.Second*5, interval).Should(Equal(0)) + + // Cleanup + Expect(k8sClient.Delete(ctx, yanet)).Should(Succeed()) + }) + + It("Should create deployments with replicas=0 when Enable=false", func() { + disabledNodeName := "test-node-disabled" + + By("Creating a test Node for disabled test") + disabledNode := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: disabledNodeName, + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + "hugepages-1Gi": resource.MustParse("45Gi"), + }, + }, + } + Expect(k8sClient.Create(ctx, disabledNode)).Should(Succeed()) + + By("Creating a Yanet resource with dataplane disabled") + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: yanetName + "-disabled", + Namespace: yanetNamespace, + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: disabledNodeName, + Type: "release", + AutoSync: true, + Dataplane: yanetv1alpha1.Dep{ + Enable: false, // Disabled + Image: "yanet-dataplane", + }, + Controlplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-controlplane", + }, + }, + } + Expect(k8sClient.Create(ctx, yanet)).Should(Succeed()) + + By("Checking dataplane deployment has 0 replicas") + dataplaneKey := types.NamespacedName{ + Name: "dataplane-" + disabledNodeName, + Namespace: yanetNamespace, + } + Eventually(func() int32 { + dep := &appsv1.Deployment{} + err := k8sClient.Get(ctx, dataplaneKey, dep) + if err != nil { + return -1 + } + return *dep.Spec.Replicas + }, timeout, interval).Should(Equal(int32(0))) + + By("Checking status shows deployment as disabled") + Eventually(func() bool { + updatedYanet := &yanetv1alpha1.Yanet{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: yanet.Name, + Namespace: yanet.Namespace, + }, updatedYanet) + if err != nil { + return false + } + for _, disabled := range updatedYanet.Status.Sync.Disabled { + if disabled == "dataplane-"+disabledNodeName { + return true + } + } + return false + }, timeout, interval).Should(BeTrue()) + + // Cleanup + Expect(k8sClient.Delete(ctx, yanet)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, disabledNode)).Should(Succeed()) + }) + }) + + Context("When YanetConfig is updated", func() { + const ( + configName = "test-config-update" + configNamespace = "default" + ) + + ctx := context.Background() + configKey := types.NamespacedName{Name: configName, Namespace: configNamespace} + + It("Should update GlobalConfig in memory", func() { + By("Creating initial YanetConfig") + config := &yanetv1alpha1.YanetConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: configName, + Namespace: configNamespace, + }, + Spec: yanetv1alpha1.YanetConfigSpec{ + Stop: false, + UpdateWindow: 30, + }, + } + Expect(k8sClient.Create(ctx, config)).Should(Succeed()) + + By("Triggering reconcile") + configReconciler := &YanetConfigReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + GlobalConfig: &yanetv1alpha1.MutexYanetConfigSpec{}, + } + _, err := configReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: configKey, + }) + Expect(err).ShouldNot(HaveOccurred()) + + By("Verifying GlobalConfig is updated") + configReconciler.GlobalConfig.Lock.Lock() + Expect(configReconciler.GlobalConfig.Config.UpdateWindow).Should(Equal(30)) + Expect(configReconciler.GlobalConfig.Config.Stop).Should(BeFalse()) + configReconciler.GlobalConfig.Lock.Unlock() + + By("Updating YanetConfig") + updatedConfig := &yanetv1alpha1.YanetConfig{} + Expect(k8sClient.Get(ctx, configKey, updatedConfig)).Should(Succeed()) + updatedConfig.Spec.UpdateWindow = 60 + updatedConfig.Spec.Stop = true + Expect(k8sClient.Update(ctx, updatedConfig)).Should(Succeed()) + + By("Triggering reconcile again") + _, err = configReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: configKey, + }) + Expect(err).ShouldNot(HaveOccurred()) + + By("Verifying GlobalConfig is updated with new values") + configReconciler.GlobalConfig.Lock.Lock() + Expect(configReconciler.GlobalConfig.Config.UpdateWindow).Should(Equal(60)) + Expect(configReconciler.GlobalConfig.Config.Stop).Should(BeTrue()) + configReconciler.GlobalConfig.Lock.Unlock() + + // Cleanup + Expect(k8sClient.Delete(ctx, config)).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/yanet_reconciler.go b/internal/controller/yanet_reconciler.go index aac832a..2dc89bb 100644 --- a/internal/controller/yanet_reconciler.go +++ b/internal/controller/yanet_reconciler.go @@ -29,6 +29,7 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" @@ -36,6 +37,8 @@ import ( manifests "github.com/yanet-platform/yanet-operator/internal/manifests" ) +const yanetFinalizer = "yanet.yanet-platform.io/finalizer" + // checkUpdateRequeue checks if enough time has passed since the last update on a different host. // logger is passed as parameter because this method does not have access to a context. func (r *YanetReconciler) checkUpdateRequeue(logger logr.Logger, updateWindow time.Duration, updateHost string) time.Duration { @@ -49,12 +52,10 @@ func (r *YanetReconciler) checkUpdateRequeue(logger logr.Logger, updateWindow ti timerExpired := r.lastUpdateTS.Add(updateWindow).Before(timeNow) if !timerExpired && updateHost != r.lastUpdateHost { retryTimer = updateWindow - timeNow.Sub(r.lastUpdateTS) - logger.Info(fmt.Sprintf( - `Reconcile: Yanet update try too early. Last update occured at: %s on host %s. Retry in %s \n`, - r.lastUpdateTS, - r.lastUpdateHost, - retryTimer, - )) + logger.Info("Yanet update try too early, will retry", + "lastUpdateTime", r.lastUpdateTS, + "lastUpdateHost", r.lastUpdateHost, + "retryIn", retryTimer) } else { r.lastUpdateTS = timeNow r.lastUpdateHost = updateHost @@ -66,6 +67,35 @@ func (r *YanetReconciler) checkUpdateRequeue(logger logr.Logger, updateWindow ti // Reconcile logic for Yanet object func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alpha1.Yanet, config yanetv1alpha1.YanetConfigSpec) (ctrl.Result, error) { logger := log.FromContext(ctx) + + // Handle deletion + if !yanet.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(yanet, yanetFinalizer) { + // Perform cleanup if needed + logger.Info("Yanet is being deleted, running cleanup") + r.Recorder.Event(yanet, v1.EventTypeNormal, "Cleanup", "Running cleanup before deletion") + + // Remove finalizer to allow deletion + controllerutil.RemoveFinalizer(yanet, yanetFinalizer) + if err := r.Update(ctx, yanet); err != nil { + logger.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + // Add finalizer if not present + if !controllerutil.ContainsFinalizer(yanet, yanetFinalizer) { + controllerutil.AddFinalizer(yanet, yanetFinalizer) + if err := r.Update(ctx, yanet); err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + // Requeue to continue with reconciliation + return ctrl.Result{Requeue: true}, nil + } + // Get nodes for capacity check nodes, err := helpers.GetNodes(ctx, r.Client) if err != nil { @@ -84,10 +114,9 @@ func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alp var requeueTimer time.Duration for _, dep := range deps { // Set Yanet instance as the owner and controller - err := ctrl.SetControllerReference(yanet, dep, r.Scheme) - if err != nil { - logger.Error(err, "Can not set Yanet instance as the owner and controller") - return ctrl.Result{}, err + if setErr := ctrl.SetControllerReference(yanet, dep, r.Scheme); setErr != nil { + logger.Error(setErr, "Can not set Yanet instance as the owner and controller") + return ctrl.Result{}, setErr } found := &appsv1.Deployment{} err = r.Client.Get( @@ -97,13 +126,14 @@ func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alp ) if err != nil && errors.IsNotFound(err) { if !yanet.Spec.AutoSync { - logger.Info(fmt.Sprintf( - "Deployment %s not found, but AutoSync for this host is disabled, do nothing.", - dep.Name, - )) + logger.Info("Deployment not found, but AutoSync disabled", + "deployment", dep.Name, + "host", yanet.Spec.NodeName) continue } - logger.Info(fmt.Sprintf("Creating new Deployment: %s in Namespace: %s", dep.Name, dep.Namespace)) + logger.Info("Creating new Deployment", + "deployment", dep.Name, + "namespace", dep.Namespace) err = r.Client.Create(ctx, dep) if err != nil { logger.Error( @@ -114,9 +144,13 @@ func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alp "Deployment.Name", dep.Name, ) + r.Recorder.Event(yanet, v1.EventTypeWarning, "DeploymentCreateFailed", + fmt.Sprintf("Failed to create deployment %s: %v", dep.Name, err)) sync.Error = append(sync.Error, dep.Name) continue } + r.Recorder.Event(yanet, v1.EventTypeNormal, "DeploymentCreated", + fmt.Sprintf("Created deployment %s", dep.Name)) // Deployment created successfully β€” record in sync status and skip diff check if *dep.Spec.Replicas == 0 { sync.Disabled = append(sync.Disabled, dep.Name) @@ -133,23 +167,26 @@ func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alp } // Check deployment for the needed to update - //r.Log.Info(fmt.Sprintf("existing deployment: %s", found.String())) if helpers.DeploymentDiff(ctx, dep, found) { - logger.Info(fmt.Sprintf("Found diff for Deployment: %s", dep.Name)) + logger.Info("Found diff for Deployment", "deployment", dep.Name) if !yanet.Spec.AutoSync { - logger.Info(fmt.Sprintf( - "Deployment %s requires update, but AutoSync for this host is disabled, do nothing.", - dep.Name, - )) + logger.Info("Deployment requires update, but AutoSync disabled", + "deployment", dep.Name, + "host", yanet.Spec.NodeName) sync.OutOfSync = append(sync.OutOfSync, dep.Name) continue } requeueTimer = r.checkUpdateRequeue(logger, updateWindow, yanet.Spec.NodeName) if requeueTimer > 0 { + r.Recorder.Event(yanet, v1.EventTypeNormal, "UpdateWindowWait", + fmt.Sprintf("Waiting %s before updating %s (UpdateWindow)", requeueTimer, dep.Name)) sync.SyncWaiting = append(sync.SyncWaiting, dep.Name) continue } - err = r.Client.Update(ctx, dep) + // Copy desired spec fields from dep to found to preserve ResourceVersion + found.Spec.Replicas = dep.Spec.Replicas + found.Spec.Template = dep.Spec.Template + err = r.Client.Update(ctx, found) if err != nil { logger.Error( err, @@ -159,9 +196,13 @@ func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alp "Deployment.Name", dep.Name, ) + r.Recorder.Event(yanet, v1.EventTypeWarning, "DeploymentUpdateFailed", + fmt.Sprintf("Failed to update deployment %s: %v", dep.Name, err)) sync.Error = append(sync.Error, dep.Name) continue } + r.Recorder.Event(yanet, v1.EventTypeNormal, "DeploymentUpdated", + fmt.Sprintf("Updated deployment %s", dep.Name)) } if *dep.Spec.Replicas == 0 { sync.Disabled = append(sync.Disabled, dep.Name) @@ -195,10 +236,20 @@ func (r *YanetReconciler) reconcilerYanet(ctx context.Context, yanet *yanetv1alp podNames := helpers.GetPods(ctx, podList.Items) + // Update conditions based on sync status + conditions := r.computeConditions(yanet, sync, podNames) + + // Update metrics for out-of-sync deployments + outOfSyncCount := len(sync.OutOfSync) + len(sync.Error) + yanetDeploymentsOutOfSync.WithLabelValues(yanet.Name, yanet.Namespace).Set(float64(outOfSyncCount)) + // Update status if needed - if !reflect.DeepEqual(podNames, yanet.Status.Pods) || !reflect.DeepEqual(sync, yanet.Status.Sync) { + if !reflect.DeepEqual(podNames, yanet.Status.Pods) || + !reflect.DeepEqual(sync, yanet.Status.Sync) || + !reflect.DeepEqual(conditions, yanet.Status.Conditions) { yanet.Status.Pods = podNames yanet.Status.Sync = sync + yanet.Status.Conditions = conditions err := r.Status().Update(ctx, yanet) if err != nil { logger.Error(err, "Failed to update Yanet status") diff --git a/internal/controller/yanet_reconciler_test.go b/internal/controller/yanet_reconciler_test.go new file mode 100644 index 0000000..d09f877 --- /dev/null +++ b/internal/controller/yanet_reconciler_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" +) + +// TestCheckUpdateRequeue tests the checkUpdateRequeue method +func TestCheckUpdateRequeue(t *testing.T) { + tests := []struct { + name string + updateWindow time.Duration + updateHost string + lastUpdateHost string + lastUpdateTS time.Time + expectRequeue bool + expectRetryDelay bool + }{ + { + name: "no update window", + updateWindow: 0, + updateHost: "host1", + lastUpdateHost: "", + lastUpdateTS: time.Time{}, + expectRequeue: false, + expectRetryDelay: false, + }, + { + name: "first update", + updateWindow: 5 * time.Minute, + updateHost: "host1", + lastUpdateHost: "", + lastUpdateTS: time.Time{}, + expectRequeue: false, + expectRetryDelay: false, + }, + { + name: "same host update allowed", + updateWindow: 5 * time.Minute, + updateHost: "host1", + lastUpdateHost: "host1", + lastUpdateTS: time.Now().Add(-1 * time.Minute), + expectRequeue: false, + expectRetryDelay: false, + }, + { + name: "different host too early", + updateWindow: 5 * time.Minute, + updateHost: "host2", + lastUpdateHost: "host1", + lastUpdateTS: time.Now().Add(-1 * time.Minute), + expectRequeue: true, + expectRetryDelay: true, + }, + { + name: "different host window expired", + updateWindow: 5 * time.Minute, + updateHost: "host2", + lastUpdateHost: "host1", + lastUpdateTS: time.Now().Add(-6 * time.Minute), + expectRequeue: false, + expectRetryDelay: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create reconciler with test state + r := &YanetReconciler{ + lock: sync.Mutex{}, + lastUpdateHost: tt.lastUpdateHost, + lastUpdateTS: tt.lastUpdateTS, + } + + // Call checkUpdateRequeue + logger := logr.Discard() + retryDelay := r.checkUpdateRequeue(logger, tt.updateWindow, tt.updateHost) + + // Check if retry delay is set + hasRetryDelay := retryDelay > 0 + if hasRetryDelay != tt.expectRetryDelay { + t.Errorf("checkUpdateRequeue() retryDelay > 0 = %v, want %v (delay: %v)", + hasRetryDelay, tt.expectRetryDelay, retryDelay) + } + + // If no retry expected and updateWindow > 0, verify state was updated + if !tt.expectRequeue && tt.updateWindow > 0 { + if r.lastUpdateHost != tt.updateHost { + t.Errorf("lastUpdateHost = %v, want %v", r.lastUpdateHost, tt.updateHost) + } + } + }) + } +} + +// TestCheckUpdateRequeue_Concurrency tests thread safety of checkUpdateRequeue +func TestCheckUpdateRequeue_Concurrency(t *testing.T) { + r := &YanetReconciler{ + lock: sync.Mutex{}, + lastUpdateHost: "", + lastUpdateTS: time.Time{}, + } + + logger := logr.Discard() + updateWindow := 5 * time.Minute + + // Run concurrent updates + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(hostNum int) { + defer wg.Done() + host := fmt.Sprintf("host%d", hostNum) + r.checkUpdateRequeue(logger, updateWindow, host) + }(i) + } + + wg.Wait() + + // Verify state is consistent (no data race) + if r.lastUpdateHost == "" { + t.Error("lastUpdateHost should be set after concurrent updates") + } + if r.lastUpdateTS.IsZero() { + t.Error("lastUpdateTS should be set after concurrent updates") + } +} diff --git a/internal/controller/yanetconfig_controller.go b/internal/controller/yanetconfig_controller.go index 1ab7836..983efb2 100644 --- a/internal/controller/yanetconfig_controller.go +++ b/internal/controller/yanetconfig_controller.go @@ -18,7 +18,7 @@ package controller import ( "context" - "fmt" + "time" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -50,31 +50,37 @@ type YanetConfigReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/reconcile func (r *YanetConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + startTime := time.Now() logger := log.FromContext(ctx) - logger.Info(fmt.Sprintf("Reconcile config loop called for NamespacedName: %s", req.NamespacedName)) + logger.Info("Reconcile config loop called", "namespacedName", req.NamespacedName) config := &yanetv1alpha1.YanetConfig{} err := r.Client.Get(ctx, req.NamespacedName, config) if err != nil { if errors.IsNotFound(err) { - logger.Info(fmt.Sprintf( - `Reconcile config: YanetConfig resource not found in cluster for NamespacedName: %s. - Ignoring since object must be deleted`, - req.NamespacedName, - )) + logger.Info("YanetConfig resource not found, ignoring since object must be deleted", + "namespacedName", req.NamespacedName) + yanetConfigReconcileTotal.WithLabelValues(req.Name, req.Namespace, "not_found").Inc() } else { logger.Error(err, "Failed to get YanetConfig object") + yanetConfigReconcileTotal.WithLabelValues(req.Name, req.Namespace, "error").Inc() return ctrl.Result{}, err } } else { - logger.Info(fmt.Sprintf("Reconcile config: successfully found YanetConfig object for NamespacedName: %s", req.NamespacedName)) - logger.Info(fmt.Sprintf("Reconcile config: update GlobalConfig with new config: %+v", config)) + logger.Info("Successfully found YanetConfig object", "namespacedName", req.NamespacedName) + logger.V(1).Info("Updating GlobalConfig with new config", "config", config.Spec) // TODO: add config validator r.GlobalConfig.Lock.Lock() - r.GlobalConfig.Config = config.Spec + r.GlobalConfig.Config = *config.Spec.DeepCopy() r.GlobalConfig.Lock.Unlock() + + yanetConfigReconcileTotal.WithLabelValues(req.Name, req.Namespace, "success").Inc() } + // Record reconciliation duration + duration := time.Since(startTime).Seconds() + yanetConfigReconcileDuration.WithLabelValues(req.Name, req.Namespace).Observe(duration) + return ctrl.Result{}, nil } diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 42a502e..9a3b83a 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -68,7 +68,7 @@ func GetNodeNames(nodeList *v1.NodeList) []string { } // DeploymentDiff make partial diff for deployments -func DeploymentDiff(ctx context.Context, first *appsv1.Deployment, second *appsv1.Deployment) bool { +func DeploymentDiff(ctx context.Context, first, second *appsv1.Deployment) bool { logger := log.FromContext(ctx) // Check Volumes if diff := cmp.Diff(first.Spec.Template.Spec.Volumes, second.Spec.Template.Spec.Volumes); diff != "" { diff --git a/internal/helpers/helpers_test.go b/internal/helpers/helpers_test.go new file mode 100644 index 0000000..9ad4a81 --- /dev/null +++ b/internal/helpers/helpers_test.go @@ -0,0 +1,623 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helpers + +import ( + "context" + "reflect" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestGetPods(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + pods []v1.Pod + expected map[v1.PodPhase][]string + }{ + { + name: "empty pods", + pods: []v1.Pod{}, + expected: map[v1.PodPhase][]string{}, + }, + { + name: "single running pod", + pods: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, + Status: v1.PodStatus{Phase: v1.PodRunning}, + }, + }, + expected: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1"}, + }, + }, + { + name: "multiple pods with different phases", + pods: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, + Status: v1.PodStatus{Phase: v1.PodRunning}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod2"}, + Status: v1.PodStatus{Phase: v1.PodPending}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod3"}, + Status: v1.PodStatus{Phase: v1.PodRunning}, + }, + }, + expected: map[v1.PodPhase][]string{ + v1.PodRunning: {"pod1", "pod3"}, + v1.PodPending: {"pod2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetPods(ctx, tt.pods) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("GetPods() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestUniqueSliceElements(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no duplicates", + input: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "with duplicates", + input: []string{"a", "b", "a", "c", "b"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "empty slice", + input: []string{}, + expected: []string{}, + }, + { + name: "all same", + input: []string{"a", "a", "a"}, + expected: []string{"a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := UniqueSliceElements(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("UniqueSliceElements() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetNodeNames(t *testing.T) { + tests := []struct { + name string + nodeList *v1.NodeList + expected []string + }{ + { + name: "empty node list", + nodeList: &v1.NodeList{}, + expected: nil, // GetNodeNames returns nil for empty list, not []string{} + }, + { + name: "single node", + nodeList: &v1.NodeList{ + Items: []v1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node1"}}, + }, + }, + expected: []string{"node1"}, + }, + { + name: "multiple nodes", + nodeList: &v1.NodeList{ + Items: []v1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "node2"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "node3"}}, + }, + }, + expected: []string{"node1", "node2", "node3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetNodeNames(tt.nodeList) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("GetNodeNames() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetTypeOpts(t *testing.T) { + releaseOpts := yanetv1alpha1.DepOpts{ + Dataplain: yanetv1alpha1.OptsNames{ + Privileged: true, + }, + } + balancerOpts := yanetv1alpha1.DepOpts{ + Dataplain: yanetv1alpha1.OptsNames{ + Privileged: false, + }, + } + + opts := yanetv1alpha1.EnabledOpts{ + Release: releaseOpts, + Balancer: balancerOpts, + } + + tests := []struct { + name string + typeStr string + expectedOk bool + expectedOpt yanetv1alpha1.DepOpts + }{ + { + name: "release type", + typeStr: "release", + expectedOk: true, + expectedOpt: releaseOpts, + }, + { + name: "balancer type", + typeStr: "balancer", + expectedOk: true, + expectedOpt: balancerOpts, + }, + { + name: "unknown type", + typeStr: "unknown", + expectedOk: false, + expectedOpt: yanetv1alpha1.DepOpts{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, result := GetTypeOpts(opts, tt.typeStr) + if ok != tt.expectedOk { + t.Errorf("GetTypeOpts() ok = %v, want %v", ok, tt.expectedOk) + } + if !reflect.DeepEqual(result, tt.expectedOpt) { + t.Errorf("GetTypeOpts() result = %v, want %v", result, tt.expectedOpt) + } + }) + } +} + +func TestDeploymentDiff(t *testing.T) { + ctx := context.Background() + replicas1 := int32(1) + replicas2 := int32(2) + + tests := []struct { + name string + first *appsv1.Deployment + second *appsv1.Deployment + expected bool + }{ + { + name: "identical deployments", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas1, + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test", Image: "test:1.0"}, + }, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas1, + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test", Image: "test:1.0"}, + }, + }, + }, + }, + }, + expected: false, + }, + { + name: "different replicas", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas1, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas2, + }, + }, + expected: true, + }, + { + name: "different container image", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test", Image: "test:1.0"}, + }, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test", Image: "test:2.0"}, + }, + }, + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeploymentDiff(ctx, tt.first, tt.second) + if result != tt.expected { + t.Errorf("DeploymentDiff() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestGetLabeledNodes tests GetLabeledNodes function +func TestGetLabeledNodes(t *testing.T) { + tests := []struct { + name string + nodeList *v1.NodeList + expected int + }{ + { + name: "empty node list", + nodeList: &v1.NodeList{ + Items: []v1.Node{}, + }, + expected: 0, + }, + { + name: "nodes with labels", + nodeList: &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{"role": "worker"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + Labels: map[string]string{"role": "master"}, + }, + }, + }, + }, + expected: 2, + }, + { + name: "nodes without labels", + nodeList: &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + }, + }, + }, + expected: 0, + }, + { + name: "mixed nodes", + nodeList: &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{"role": "worker"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + }, + }, + }, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetLabeledNodes(tt.nodeList) + if len(result) != tt.expected { + t.Errorf("GetLabeledNodes() returned %d nodes, want %d", len(result), tt.expected) + } + }) + } +} + +// TestDeploymentDiff_EdgeCases tests edge cases for DeploymentDiff +func TestDeploymentDiff_EdgeCases(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + first *appsv1.Deployment + second *appsv1.Deployment + expected bool + }{ + { + name: "different volumes", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Volumes: []v1.Volume{ + {Name: "vol1"}, + }, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Volumes: []v1.Volume{ + {Name: "vol2"}, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "different tolerations", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Tolerations: []v1.Toleration{ + {Key: "key1", Operator: v1.TolerationOpExists}, + }, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Tolerations: []v1.Toleration{ + {Key: "key2", Operator: v1.TolerationOpExists}, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "different annotations", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"key1": "value1"}, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"key2": "value2"}, + }, + }, + }, + }, + expected: true, + }, + { + name: "different HostIPC", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + HostIPC: true, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + HostIPC: false, + }, + }, + }, + }, + expected: true, + }, + { + name: "different init containers", + first: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + InitContainers: []v1.Container{ + {Name: "init1", Image: "busybox"}, + }, + }, + }, + }, + }, + second: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + InitContainers: []v1.Container{ + {Name: "init2", Image: "alpine"}, + }, + }, + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeploymentDiff(ctx, tt.first, tt.second) + if result != tt.expected { + t.Errorf("DeploymentDiff() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestGetNodes tests GetNodes function with fake client +func TestGetNodes(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + nodes []v1.Node + expectedCount int + expectError bool + }{ + { + name: "empty cluster", + nodes: []v1.Node{}, + expectedCount: 0, + expectError: false, + }, + { + name: "single node", + nodes: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + }, + }, + expectedCount: 1, + expectError: false, + }, + { + name: "multiple nodes", + nodes: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node3", + }, + }, + }, + expectedCount: 3, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create fake client with nodes + fakeClient := fake.NewClientBuilder(). + WithObjects(nodesToObjects(tt.nodes)...). + Build() + + // Call GetNodes + nodeList, err := GetNodes(ctx, fakeClient) + + // Check error + if (err != nil) != tt.expectError { + t.Errorf("GetNodes() error = %v, expectError %v", err, tt.expectError) + return + } + + // Check node count + if len(nodeList.Items) != tt.expectedCount { + t.Errorf("GetNodes() returned %d nodes, want %d", len(nodeList.Items), tt.expectedCount) + } + }) + } +} + +// nodesToObjects converts []v1.Node to []client.Object for fake client +func nodesToObjects(nodes []v1.Node) []client.Object { + objects := make([]client.Object, len(nodes)) + for i := range nodes { + objects[i] = &nodes[i] + } + return objects +} diff --git a/internal/helpers/http_getters_test.go b/internal/helpers/http_getters_test.go new file mode 100644 index 0000000..4df2f74 --- /dev/null +++ b/internal/helpers/http_getters_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helpers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestHttpGet_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test-response")) + })) + defer server.Close() + + result, err := HttpGet(server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != "test-response" { + t.Errorf("got %q, want %q", result, "test-response") + } +} + +func TestHttpGet_ErrorStatus(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + }{ + { + name: "404 Not Found", + statusCode: http.StatusNotFound, + body: "not found", + }, + { + name: "500 Internal Server Error", + statusCode: http.StatusInternalServerError, + body: "server error", + }, + { + name: "403 Forbidden", + statusCode: http.StatusForbidden, + body: "forbidden", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + _, err := HttpGet(server.URL) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "HTTP") { + t.Errorf("error should contain 'HTTP', got: %v", err) + } + }) + } +} + +func TestHttpGet_NetworkError(t *testing.T) { + // Use invalid URL to trigger network error + _, err := HttpGet("http://invalid-host-that-does-not-exist-12345.local") + if err == nil { + t.Fatal("expected error for invalid host, got nil") + } +} + +func TestHttpGet_Timeout(t *testing.T) { + // This test would take 30+ seconds to run, so we skip it in normal test runs + // It's here for documentation purposes + t.Skip("Skipping timeout test as it takes 30+ seconds") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(35 * time.Second) // Longer than httpClient timeout (30s) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := HttpGet(server.URL) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + + if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "deadline") { + t.Errorf("expected timeout/deadline error, got: %v", err) + } +} + +func TestHttpGet_EmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // No body + })) + defer server.Close() + + result, err := HttpGet(server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != "" { + t.Errorf("got %q, want empty string", result) + } +} diff --git a/internal/manifests/announcer.go b/internal/manifests/announcer.go index dfdfb4e..b538bce 100644 --- a/internal/manifests/announcer.go +++ b/internal/manifests/announcer.go @@ -8,7 +8,6 @@ import ( "github.com/yanet-platform/yanet-operator/internal/helpers" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -46,100 +45,88 @@ func DeploymentForAnnouncer( log := log.FromContext(ctx) ok, perTypeOpts := helpers.GetTypeOpts(config.EnabledOpts, m.Spec.Type) if !ok { - log.Info(fmt.Sprintf("typeOpts is not specified for %s", m.Spec.Type)) + log.Info("typeOpts is not specified", "type", m.Spec.Type) } - // Filling in all init containers + + // Build init containers (wait-bird + additional) initContainers := newAnnouncerInitContainers() additionalInitContainers := GetAdditionalInitContainers( - config.AdditionalOpts.InitContainers, // all available initContainers in yanetConfig spec - perTypeOpts.Announcer.InitContainers, // initContainers enabled for specific type in global config + config.AdditionalOpts.InitContainers, + perTypeOpts.Announcer.InitContainers, ) initContainers = append(initContainers, additionalInitContainers...) + // Prepare poststart hook poststart := GetPostStartExec(config.AdditionalOpts.PostStart, perTypeOpts.Announcer.PostStart) - // Creating deployment based on previously created structures + // Determine image and tag + image := m.Spec.Announcer.Image + tag := m.Spec.Tag + if m.Spec.Announcer.Tag != "" { + tag = m.Spec.Announcer.Tag + } + + // Calculate replicas replicas := int32(0) if m.Spec.Announcer.Enable { replicas = 1 } - depName := fmt.Sprintf("announcer-%s", m.Spec.NodeName) - image := fmt.Sprintf("%s:%s", m.Spec.Announcer.Image, m.Spec.Tag) - if m.Spec.Announcer.Tag != "" { - image = fmt.Sprintf("%s:%s", m.Spec.Announcer.Image, m.Spec.Announcer.Tag) - } - if m.Spec.Registry != "" { - image = fmt.Sprintf("%s/%s", m.Spec.Registry, image) - } - dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Namespace: m.Namespace, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: LabelsForYanet(nil, m, "announcer"), - }, - Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, - Template: v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Annotations: AnnotationsForYanet(config.AdditionalOpts.Annotations, perTypeOpts.Announcer.Annotations), - Labels: LabelsForYanet(nil, m, "announcer"), - }, - Spec: v1.PodSpec{ - HostNetwork: true, - HostIPC: perTypeOpts.Announcer.HostIpc, - InitContainers: initContainers, - Containers: []v1.Container{ - { - Image: image, - ImagePullPolicy: v1.PullIfNotPresent, - Name: "announcer", - Command: []string{"/usr/bin/yanet-announcer"}, - Args: []string{"--run"}, - Resources: GetResources( - ctx, - m.Spec.NodeName, - perTypeOpts.Announcer.Resources, - nodes, - false, - ), - Lifecycle: &v1.Lifecycle{ - PostStart: &v1.LifecycleHandler{ - Exec: &v1.ExecAction{Command: poststart}, - }, - }, - VolumeMounts: []v1.VolumeMount{ - {Name: "etc-yanet", MountPath: "/etc/yanet"}, - {Name: "run-yanet", MountPath: "/run/yanet"}, - {Name: "run-bird", MountPath: "/run/bird"}, - }, - TerminationMessagePath: "/dev/stdout", - TerminationMessagePolicy: "File", - SecurityContext: &v1.SecurityContext{ - Privileged: &perTypeOpts.Announcer.Privileged, - Capabilities: &v1.Capabilities{ - Add: []v1.Capability{ - "NET_ADMIN", - "NET_BIND_SERVICE", - "IPC_LOCK", - "SYS_MODULE", - "SYS_NICE", - }, - }, - }, - }, - }, - NodeSelector: map[string]string{ - "kubernetes.io/hostname": m.Spec.NodeName, - }, - Tolerations: TolerationsForYanet(), - Volumes: GetVolumes([]string{"/etc/yanet", "/run/yanet", "/run/bird"}), - }, + + // Build security context + privileged := perTypeOpts.Announcer.Privileged + securityCtx := &v1.SecurityContext{ + Privileged: &privileged, + Capabilities: &v1.Capabilities{ + Add: []v1.Capability{ + "NET_ADMIN", + "NET_BIND_SERVICE", + "IPC_LOCK", + "SYS_MODULE", + "SYS_NICE", }, }, } - return dep + + // Build deployment using builder pattern + return NewDeploymentBuilder(). + WithYanet(m). + WithName(fmt.Sprintf("announcer-%s", m.Spec.NodeName)). + WithComponentName("announcer"). + WithReplicas(replicas). + WithImage(m.Spec.Registry, image, tag). + WithHostNetwork(true). + WithHostIPC(perTypeOpts.Announcer.HostIpc). + WithAnnotations(AnnotationsForYanet( + config.AdditionalOpts.Annotations, + perTypeOpts.Announcer.Annotations, + )). + WithNodeSelector(map[string]string{ + "kubernetes.io/hostname": m.Spec.NodeName, + }). + WithTolerations(TolerationsForYanet()). + WithContainer("announcer", + []string{"/usr/bin/yanet-announcer"}, + []string{"--run"}, + ). + WithVolumeMounts([]v1.VolumeMount{ + {Name: "etc-yanet", MountPath: "/etc/yanet"}, + {Name: "run-yanet", MountPath: "/run/yanet"}, + {Name: "run-bird", MountPath: "/run/bird"}, + }). + WithResources(GetResources( + ctx, + m.Spec.NodeName, + perTypeOpts.Announcer.Resources, + nodes, + false, + )). + WithSecurityContext(securityCtx). + WithLifecycle(&v1.Lifecycle{ + PostStart: &v1.LifecycleHandler{ + Exec: &v1.ExecAction{Command: poststart}, + }, + }). + WithInitContainers(initContainers). + WithVolumes(GetVolumes([]string{"/etc/yanet", "/run/yanet", "/run/bird"})). + Build() } diff --git a/internal/manifests/announcer_test.go b/internal/manifests/announcer_test.go new file mode 100644 index 0000000..e6c2529 --- /dev/null +++ b/internal/manifests/announcer_test.go @@ -0,0 +1,251 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manifests + +import ( + "context" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestDeploymentForAnnouncer verifies announcer deployment generation +func TestDeploymentForAnnouncer(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", // Important: must match config.EnabledOpts.Release + Tag: "1.0.0", + Registry: "docker.io/test", + Announcer: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-announcer", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{ + EnabledOpts: yanetv1alpha1.EnabledOpts{ + Release: yanetv1alpha1.DepOpts{ + Announcer: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + Privileged: false, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("4"), + "memory": resource.MustParse("32Gi"), + }, + Requests: v1.ResourceList{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("4Gi"), + }, + }, + }, + }, + }, + AdditionalOpts: yanetv1alpha1.AdditionalOpts{ + Annotations: []yanetv1alpha1.NamedAnnotations{ + { + Name: "checkpointer", + Annotations: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + "checkpointer.ydb.tech/manual-recovery": "true", + }, + }, + }, + }, + } + + nodes := v1.NodeList{} + + dep := DeploymentForAnnouncer(context.Background(), yanet, config, nodes) + + // Test 1: Deployment name + expectedName := "announcer-test-node" + if dep.Name != expectedName { + t.Errorf("expected name %q, got %q", expectedName, dep.Name) + } + + // Test 2: Replicas + if *dep.Spec.Replicas != 1 { + t.Errorf("expected 1 replica, got %d", *dep.Spec.Replicas) + } + + // Test 3: Container + if len(dep.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(dep.Spec.Template.Spec.Containers)) + } + + container := dep.Spec.Template.Spec.Containers[0] + if container.Name != "announcer" { + t.Errorf("expected container name 'announcer', got %q", container.Name) + } + + // Test 4: Image + expectedImage := "docker.io/test/yanet-announcer:1.0.0" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } + + // Test 5: Command and Args (ΠΈΠ· production test-case.txt строки 388-389) + expectedCommand := []string{"/usr/bin/yanet-announcer"} + if len(container.Command) != 1 || container.Command[0] != expectedCommand[0] { + t.Errorf("expected command %v, got %v", expectedCommand, container.Command) + } + + expectedArgs := []string{"--run"} + if len(container.Args) != 1 || container.Args[0] != "--run" { + t.Errorf("expected args %v, got %v", expectedArgs, container.Args) + } + + // Test 6: Privileged (ΠΈΠ· production test-case.txt строка 415) + if container.SecurityContext == nil { + t.Fatal("expected SecurityContext to be set") + } + if *container.SecurityContext.Privileged { + t.Error("expected Privileged to be false for announcer") + } + + // Test 7: Resources (ΠΈΠ· production test-case.txt строки 400-406) + cpuLimit := container.Resources.Limits["cpu"] + expectedCPU := resource.MustParse("4") + if !cpuLimit.Equal(expectedCPU) { + t.Errorf("expected CPU limit %v, got %v", expectedCPU, cpuLimit) + } + + memLimit := container.Resources.Limits["memory"] + expectedMem := resource.MustParse("32Gi") + if !memLimit.Equal(expectedMem) { + t.Errorf("expected memory limit %v, got %v", expectedMem, memLimit) + } + + // Test 8: Annotations (ΠΈΠ· production test-case.txt строки 375-377) + annotations := dep.Spec.Template.ObjectMeta.Annotations + if annotations["checkpointer.ydb.tech/checkpoint"] != "true" { + t.Error("expected checkpointer annotation to be set") + } + + // Test 9: Volumes (ΠΈΠ· production test-case.txt строки 456-468) + // Announcer Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΠΌΠ΅Ρ‚ΡŒ 3 volume: etc-yanet, run-yanet, run-bird + expectedVolumes := []string{"etc-yanet", "run-yanet", "run-bird"} + if len(dep.Spec.Template.Spec.Volumes) != len(expectedVolumes) { + t.Errorf("expected %d volumes, got %d", len(expectedVolumes), len(dep.Spec.Template.Spec.Volumes)) + } + + // Test 10: VolumeMounts (ΠΈΠ· production test-case.txt строки 418-424) + expectedMounts := 3 + if len(container.VolumeMounts) != expectedMounts { + t.Errorf("expected %d volume mounts, got %d", expectedMounts, len(container.VolumeMounts)) + } +} + +// TestDeploymentForAnnouncer_InitContainer verifies wait-bird init container +// Announcer depends on bird and must wait for bird.ctl +func TestDeploymentForAnnouncer_InitContainer(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", + Announcer: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-announcer", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForAnnouncer(context.Background(), yanet, config, nodes) + + // Test: Must have at least 1 init container (wait-bird) + if len(dep.Spec.Template.Spec.InitContainers) < 1 { + t.Fatal("expected at least 1 init container (wait-bird)") + } + + initContainer := dep.Spec.Template.Spec.InitContainers[0] + + // Test: Init container name + if initContainer.Name != "wait-bird" { + t.Errorf("expected init container name 'wait-bird', got %q", initContainer.Name) + } + + // Test: Image should be busybox + if initContainer.Image != "busybox" { + t.Errorf("expected init container image 'busybox', got %q", initContainer.Image) + } + + // Test: Command should be /bin/sh + if len(initContainer.Command) != 1 || initContainer.Command[0] != "/bin/sh" { + t.Errorf("expected command [/bin/sh], got %v", initContainer.Command) + } + + // Test: Args should contain logic to wait for bird.ctl + if len(initContainer.Args) < 2 { + t.Fatal("expected at least 2 args (-c and script)") + } + + script := initContainer.Args[1] + if !contains(script, "bird.ctl") { + t.Error("expected init container script to wait for bird.ctl") + } + + if !contains(script, "/run/bird/bird.ctl") { + t.Error("expected init container script to check /run/bird/bird.ctl") + } + + // Test: VolumeMounts should include run-bird + hasRunBird := false + for _, mount := range initContainer.VolumeMounts { + if mount.Name == "run-bird" && mount.MountPath == "/run/bird" { + hasRunBird = true + break + } + } + if !hasRunBird { + t.Error("expected init container to have run-bird volume mount") + } +} + +// TestDeploymentForAnnouncer_Disabled verifies deployment with replicas=0 when Enable=false +func TestDeploymentForAnnouncer_Disabled(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Announcer: yanetv1alpha1.Dep{ + Enable: false, // Disabled + Image: "yanet-announcer", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForAnnouncer(context.Background(), yanet, config, nodes) + + if *dep.Spec.Replicas != 0 { + t.Errorf("expected 0 replicas for disabled deployment, got %d", *dep.Spec.Replicas) + } +} diff --git a/internal/manifests/bird.go b/internal/manifests/bird.go index f712efa..dd3a31c 100644 --- a/internal/manifests/bird.go +++ b/internal/manifests/bird.go @@ -8,7 +8,6 @@ import ( "github.com/yanet-platform/yanet-operator/internal/helpers" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -56,100 +55,88 @@ func DeploymentForBird( log := log.FromContext(ctx) ok, perTypeOpts := helpers.GetTypeOpts(config.EnabledOpts, m.Spec.Type) if !ok { - log.Info(fmt.Sprintf("typeOpts is not specified for %s", m.Spec.Type)) + log.Info("typeOpts is not specified", "type", m.Spec.Type) } - // Filling in all init containers + + // Build init containers (wait-controlplane + additional) initContainers := newBirdInitContainers(m) additionalInitContainers := GetAdditionalInitContainers( - config.AdditionalOpts.InitContainers, // all available initContainers in yanetConfig spec - perTypeOpts.Bird.InitContainers, // initContainers enabled for specific type in global config + config.AdditionalOpts.InitContainers, + perTypeOpts.Bird.InitContainers, ) initContainers = append(initContainers, additionalInitContainers...) + // Prepare poststart hook poststart := GetPostStartExec(config.AdditionalOpts.PostStart, perTypeOpts.Bird.PostStart) - // Creating deployment based on previously created structures + // Determine image and tag + image := m.Spec.Bird.Image + tag := m.Spec.Tag + if m.Spec.Bird.Tag != "" { + tag = m.Spec.Bird.Tag + } + + // Calculate replicas replicas := int32(0) if m.Spec.Bird.Enable { replicas = 1 } - depName := fmt.Sprintf("bird-%s", m.Spec.NodeName) - image := fmt.Sprintf("%s:%s", m.Spec.Bird.Image, m.Spec.Tag) - if m.Spec.Bird.Tag != "" { - image = fmt.Sprintf("%s:%s", m.Spec.Bird.Image, m.Spec.Bird.Tag) - } - if m.Spec.Registry != "" { - image = fmt.Sprintf("%s/%s", m.Spec.Registry, image) - } - dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Namespace: m.Namespace, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: LabelsForYanet(nil, m, "bird"), - }, - Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, - Template: v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Annotations: AnnotationsForYanet(config.AdditionalOpts.Annotations, perTypeOpts.Bird.Annotations), - Labels: LabelsForYanet(nil, m, "bird"), - }, - Spec: v1.PodSpec{ - HostNetwork: true, - HostIPC: perTypeOpts.Bird.HostIpc, - InitContainers: initContainers, - Containers: []v1.Container{ - { - Image: image, - ImagePullPolicy: v1.PullIfNotPresent, - Name: "bird", - Command: []string{"/usr/sbin/bird"}, - Args: []string{"-f"}, - Resources: GetResources( - ctx, - m.Spec.NodeName, - perTypeOpts.Bird.Resources, - nodes, - false, - ), - Lifecycle: &v1.Lifecycle{ - PostStart: &v1.LifecycleHandler{ - Exec: &v1.ExecAction{Command: poststart}, - }, - }, - VolumeMounts: []v1.VolumeMount{ - {Name: "etc-bird", MountPath: "/etc/bird"}, - {Name: "run-yanet", MountPath: "/run/yanet"}, - {Name: "run-bird", MountPath: "/run/bird"}, - }, - TerminationMessagePath: "/dev/stdout", - TerminationMessagePolicy: "File", - SecurityContext: &v1.SecurityContext{ - Privileged: &perTypeOpts.Bird.Privileged, - Capabilities: &v1.Capabilities{ - Add: []v1.Capability{ - "NET_ADMIN", - "NET_BIND_SERVICE", - "IPC_LOCK", - "SYS_MODULE", - "SYS_NICE", - }, - }, - }, - }, - }, - NodeSelector: map[string]string{ - "kubernetes.io/hostname": m.Spec.NodeName, - }, - Tolerations: TolerationsForYanet(), - Volumes: GetVolumes([]string{"/etc/bird", "/run/yanet", "/run/bird"}), - }, + + // Build security context + privileged := perTypeOpts.Bird.Privileged + securityCtx := &v1.SecurityContext{ + Privileged: &privileged, + Capabilities: &v1.Capabilities{ + Add: []v1.Capability{ + "NET_ADMIN", + "NET_BIND_SERVICE", + "IPC_LOCK", + "SYS_MODULE", + "SYS_NICE", }, }, } - return dep + + // Build deployment using builder pattern + return NewDeploymentBuilder(). + WithYanet(m). + WithName(fmt.Sprintf("bird-%s", m.Spec.NodeName)). + WithComponentName("bird"). + WithReplicas(replicas). + WithImage(m.Spec.Registry, image, tag). + WithHostNetwork(true). + WithHostIPC(perTypeOpts.Bird.HostIpc). + WithAnnotations(AnnotationsForYanet( + config.AdditionalOpts.Annotations, + perTypeOpts.Bird.Annotations, + )). + WithNodeSelector(map[string]string{ + "kubernetes.io/hostname": m.Spec.NodeName, + }). + WithTolerations(TolerationsForYanet()). + WithContainer("bird", + []string{"/usr/sbin/bird"}, + []string{"-f"}, + ). + WithVolumeMounts([]v1.VolumeMount{ + {Name: "etc-bird", MountPath: "/etc/bird"}, + {Name: "run-yanet", MountPath: "/run/yanet"}, + {Name: "run-bird", MountPath: "/run/bird"}, + }). + WithResources(GetResources( + ctx, + m.Spec.NodeName, + perTypeOpts.Bird.Resources, + nodes, + false, + )). + WithSecurityContext(securityCtx). + WithLifecycle(&v1.Lifecycle{ + PostStart: &v1.LifecycleHandler{ + Exec: &v1.ExecAction{Command: poststart}, + }, + }). + WithInitContainers(initContainers). + WithVolumes(GetVolumes([]string{"/etc/bird", "/run/yanet", "/run/bird"})). + Build() } diff --git a/internal/manifests/bird_test.go b/internal/manifests/bird_test.go new file mode 100644 index 0000000..a91dd09 --- /dev/null +++ b/internal/manifests/bird_test.go @@ -0,0 +1,289 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manifests + +import ( + "context" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestDeploymentForBird verifies bird deployment generation +func TestDeploymentForBird(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", + Tag: "1.0.0", + Registry: "docker.io/test", + Bird: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-bird", + Tag: "2.0.12", // Custom tag for bird + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{ + EnabledOpts: yanetv1alpha1.EnabledOpts{ + Release: yanetv1alpha1.DepOpts{ + Bird: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + Privileged: false, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("6"), + "memory": resource.MustParse("64Gi"), + }, + Requests: v1.ResourceList{ + "cpu": resource.MustParse("100m"), + "memory": resource.MustParse("4Gi"), + }, + }, + }, + }, + }, + AdditionalOpts: yanetv1alpha1.AdditionalOpts{ + Annotations: []yanetv1alpha1.NamedAnnotations{ + { + Name: "checkpointer", + Annotations: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + "checkpointer.ydb.tech/manual-recovery": "true", + }, + }, + }, + }, + } + + nodes := v1.NodeList{} + + dep := DeploymentForBird(context.Background(), yanet, config, nodes) + + // Test deployment name + expectedName := "bird-test-node" + if dep.Name != expectedName { + t.Errorf("expected name %q, got %q", expectedName, dep.Name) + } + + // Test replicas + if *dep.Spec.Replicas != 1 { + t.Errorf("expected 1 replica, got %d", *dep.Spec.Replicas) + } + + // Test container + if len(dep.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(dep.Spec.Template.Spec.Containers)) + } + + container := dep.Spec.Template.Spec.Containers[0] + if container.Name != "bird" { + t.Errorf("expected container name 'bird', got %q", container.Name) + } + + // Test image with custom tag + expectedImage := "docker.io/test/yanet-bird:2.0.12" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } + + // Test command and args + expectedCommand := []string{"/usr/sbin/bird"} + if len(container.Command) != 1 || container.Command[0] != expectedCommand[0] { + t.Errorf("expected command %v, got %v", expectedCommand, container.Command) + } + + expectedArgs := []string{"-f"} + if len(container.Args) != 1 || container.Args[0] != "-f" { + t.Errorf("expected args %v, got %v", expectedArgs, container.Args) + } + + // Test privileged + if container.SecurityContext == nil { + t.Fatal("expected SecurityContext to be set") + } + if *container.SecurityContext.Privileged { + t.Error("expected Privileged to be false for bird") + } + + // Test resources + cpuLimit := container.Resources.Limits["cpu"] + expectedCPU := resource.MustParse("6") + if !cpuLimit.Equal(expectedCPU) { + t.Errorf("expected CPU limit %v, got %v", expectedCPU, cpuLimit) + } + + memLimit := container.Resources.Limits["memory"] + expectedMem := resource.MustParse("64Gi") + if !memLimit.Equal(expectedMem) { + t.Errorf("expected memory limit %v, got %v", expectedMem, memLimit) + } + + // Test annotations + annotations := dep.Spec.Template.ObjectMeta.Annotations + if annotations["checkpointer.ydb.tech/checkpoint"] != "true" { + t.Error("expected checkpointer annotation to be set") + } + + // Test volumes - bird requires etc-bird, run-yanet, run-bird + expectedVolumes := []string{"etc-bird", "run-yanet", "run-bird"} + if len(dep.Spec.Template.Spec.Volumes) != len(expectedVolumes) { + t.Errorf("expected %d volumes, got %d", len(expectedVolumes), len(dep.Spec.Template.Spec.Volumes)) + } + + // Test volume mounts + expectedMounts := 3 + if len(container.VolumeMounts) != expectedMounts { + t.Errorf("expected %d volume mounts, got %d", expectedMounts, len(container.VolumeMounts)) + } +} + +// TestDeploymentForBird_InitContainer verifies wait-controlplane init container +// Bird depends on controlplane and must wait for controlplane.sock +func TestDeploymentForBird_InitContainer(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", + Tag: "1.0.0", + Registry: "docker.io/test", + Controlplane: yanetv1alpha1.Dep{ + Image: "yanet-controlplane", + }, + Bird: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-bird", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForBird(context.Background(), yanet, config, nodes) + + // Test: Must have at least 1 init container (wait-controlplane) + if len(dep.Spec.Template.Spec.InitContainers) < 1 { + t.Fatal("expected at least 1 init container (wait-controlplane)") + } + + initContainer := dep.Spec.Template.Spec.InitContainers[0] + + // Test: Init container name + if initContainer.Name != "wait-controlplane" { + t.Errorf("expected init container name 'wait-controlplane', got %q", initContainer.Name) + } + + // Test: Init container uses controlplane image for yanet-cli + expectedInitImage := "docker.io/test/yanet-controlplane:1.0.0" + if initContainer.Image != expectedInitImage { + t.Errorf("expected init container image %q, got %q", expectedInitImage, initContainer.Image) + } + + // Test: Command should be /bin/sh + if len(initContainer.Command) != 1 || initContainer.Command[0] != "/bin/sh" { + t.Errorf("expected command [/bin/sh], got %v", initContainer.Command) + } + + // Test: Args should contain logic to wait for controlplane.sock + if len(initContainer.Args) < 2 { + t.Fatal("expected at least 2 args (-c and script)") + } + + script := initContainer.Args[1] + if !contains(script, "controlplane.sock") { + t.Error("expected init container script to wait for controlplane.sock") + } + + if !contains(script, "/run/yanet/controlplane.sock") { + t.Error("expected init container script to check /run/yanet/controlplane.sock") + } + + // Test: Script should use yanet-cli for verification + if !contains(script, "yanet-cli version") { + t.Error("expected init container script to use yanet-cli version") + } + + // Test: VolumeMounts should include run-yanet + hasRunYanet := false + for _, mount := range initContainer.VolumeMounts { + if mount.Name == "run-yanet" && mount.MountPath == "/run/yanet" { + hasRunYanet = true + break + } + } + if !hasRunYanet { + t.Error("expected init container to have run-yanet volume mount") + } +} + +// TestDeploymentForBird_Disabled verifies deployment with replicas=0 when Enable=false +func TestDeploymentForBird_Disabled(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Bird: yanetv1alpha1.Dep{ + Enable: false, + Image: "yanet-bird", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForBird(context.Background(), yanet, config, nodes) + + if *dep.Spec.Replicas != 0 { + t.Errorf("expected 0 replicas for disabled deployment, got %d", *dep.Spec.Replicas) + } +} + +// TestDeploymentForBird_CustomTagOverride verifies custom tag overrides global tag +// Bird often has its own version tag different from other components +func TestDeploymentForBird_CustomTagOverride(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Tag: "1.0.0", + Bird: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-bird", + Tag: "2.0.12-7", // Custom tag + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForBird(context.Background(), yanet, config, nodes) + + container := dep.Spec.Template.Spec.Containers[0] + expectedImage := "yanet-bird:2.0.12-7" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } +} diff --git a/internal/manifests/builder.go b/internal/manifests/builder.go new file mode 100644 index 0000000..512abf4 --- /dev/null +++ b/internal/manifests/builder.go @@ -0,0 +1,279 @@ +package manifests + +import ( + "fmt" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DeploymentBuilder provides a fluent interface for building Kubernetes Deployments +// for yanet components (dataplane, controlplane, announcer, bird). +// It encapsulates common deployment configuration logic and reduces code duplication. +type DeploymentBuilder struct { + // Core fields + name string + namespace string + componentName string + replicas int32 + + // Yanet context + yanet *yanetv1alpha1.Yanet + + // Image configuration + registry string + image string + tag string + + // Pod configuration + hostNetwork bool + hostIPC bool + nodeSelector map[string]string + tolerations []v1.Toleration + annotations map[string]string + labels map[string]string + + // Container configuration + containerName string + command []string + args []string + env []v1.EnvVar + volumeMounts []v1.VolumeMount + resources v1.ResourceRequirements + securityCtx *v1.SecurityContext + lifecycle *v1.Lifecycle + + // Init containers + initContainers []v1.Container + + // Volumes + volumes []v1.Volume +} + +// NewDeploymentBuilder creates a new builder instance with sensible defaults +func NewDeploymentBuilder() *DeploymentBuilder { + return &DeploymentBuilder{ + hostNetwork: true, // Default for yanet components + replicas: 1, // Default replica count + } +} + +// WithName sets deployment name +func (b *DeploymentBuilder) WithName(name string) *DeploymentBuilder { + b.name = name + return b +} + +// WithNamespace sets namespace +func (b *DeploymentBuilder) WithNamespace(namespace string) *DeploymentBuilder { + b.namespace = namespace + return b +} + +// WithComponentName sets component name (dataplane, controlplane, announcer, bird) +func (b *DeploymentBuilder) WithComponentName(name string) *DeploymentBuilder { + b.componentName = name + return b +} + +// WithYanet sets Yanet CR reference and automatically sets namespace +func (b *DeploymentBuilder) WithYanet(yanet *yanetv1alpha1.Yanet) *DeploymentBuilder { + b.yanet = yanet + b.namespace = yanet.Namespace + return b +} + +// WithReplicas sets replica count +func (b *DeploymentBuilder) WithReplicas(replicas int32) *DeploymentBuilder { + b.replicas = replicas + return b +} + +// WithImage sets image configuration (registry, image name, tag) +func (b *DeploymentBuilder) WithImage(registry, image, tag string) *DeploymentBuilder { + b.registry = registry + b.image = image + b.tag = tag + return b +} + +// WithHostNetwork enables/disables host network +func (b *DeploymentBuilder) WithHostNetwork(enabled bool) *DeploymentBuilder { + b.hostNetwork = enabled + return b +} + +// WithHostIPC enables/disables host IPC +func (b *DeploymentBuilder) WithHostIPC(enabled bool) *DeploymentBuilder { + b.hostIPC = enabled + return b +} + +// WithNodeSelector sets node selector +func (b *DeploymentBuilder) WithNodeSelector(selector map[string]string) *DeploymentBuilder { + b.nodeSelector = selector + return b +} + +// WithTolerations sets tolerations +func (b *DeploymentBuilder) WithTolerations(tolerations []v1.Toleration) *DeploymentBuilder { + b.tolerations = tolerations + return b +} + +// WithAnnotations sets pod annotations +func (b *DeploymentBuilder) WithAnnotations(annotations map[string]string) *DeploymentBuilder { + b.annotations = annotations + return b +} + +// WithLabels sets pod labels +func (b *DeploymentBuilder) WithLabels(labels map[string]string) *DeploymentBuilder { + b.labels = labels + return b +} + +// WithContainer sets main container configuration (name, command, args) +func (b *DeploymentBuilder) WithContainer(name string, command, args []string) *DeploymentBuilder { + b.containerName = name + b.command = command + b.args = args + return b +} + +// WithEnv sets environment variables +func (b *DeploymentBuilder) WithEnv(env []v1.EnvVar) *DeploymentBuilder { + b.env = env + return b +} + +// WithVolumeMounts sets volume mounts +func (b *DeploymentBuilder) WithVolumeMounts(mounts []v1.VolumeMount) *DeploymentBuilder { + b.volumeMounts = mounts + return b +} + +// WithResources sets resource requirements +func (b *DeploymentBuilder) WithResources(resources v1.ResourceRequirements) *DeploymentBuilder { + b.resources = resources + return b +} + +// WithSecurityContext sets security context +func (b *DeploymentBuilder) WithSecurityContext(ctx *v1.SecurityContext) *DeploymentBuilder { + b.securityCtx = ctx + return b +} + +// WithLifecycle sets lifecycle hooks +func (b *DeploymentBuilder) WithLifecycle(lifecycle *v1.Lifecycle) *DeploymentBuilder { + b.lifecycle = lifecycle + return b +} + +// WithInitContainers sets init containers +func (b *DeploymentBuilder) WithInitContainers(containers []v1.Container) *DeploymentBuilder { + b.initContainers = containers + return b +} + +// AddInitContainer adds a single init container +func (b *DeploymentBuilder) AddInitContainer(container v1.Container) *DeploymentBuilder { + b.initContainers = append(b.initContainers, container) + return b +} + +// WithVolumes sets volumes +func (b *DeploymentBuilder) WithVolumes(volumes []v1.Volume) *DeploymentBuilder { + b.volumes = volumes + return b +} + +// AddVolume adds a single volume +func (b *DeploymentBuilder) AddVolume(volume v1.Volume) *DeploymentBuilder { + b.volumes = append(b.volumes, volume) + return b +} + +// Build constructs the final Deployment object +func (b *DeploymentBuilder) Build() *appsv1.Deployment { + // Build full image name + fullImage := b.buildImageName() + + // Build labels + labels := b.buildLabels() + + // Build deployment + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: b.name, + Namespace: b.namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &b.replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RecreateDeploymentStrategyType, + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: b.name, + Annotations: b.annotations, + Labels: labels, + }, + Spec: v1.PodSpec{ + HostNetwork: b.hostNetwork, + HostIPC: b.hostIPC, + InitContainers: b.initContainers, + Containers: []v1.Container{ + { + Name: b.containerName, + Image: fullImage, + ImagePullPolicy: v1.PullIfNotPresent, + Command: b.command, + Args: b.args, + Env: b.env, + Resources: b.resources, + VolumeMounts: b.volumeMounts, + SecurityContext: b.securityCtx, + Lifecycle: b.lifecycle, + TerminationMessagePath: "/dev/stdout", + TerminationMessagePolicy: "File", + }, + }, + Volumes: b.volumes, + NodeSelector: b.nodeSelector, + Tolerations: b.tolerations, + }, + }, + }, + } + + return dep +} + +// buildImageName constructs full image name with registry and tag +func (b *DeploymentBuilder) buildImageName() string { + image := fmt.Sprintf("%s:%s", b.image, b.tag) + if b.registry != "" { + image = fmt.Sprintf("%s/%s", b.registry, image) + } + return image +} + +// buildLabels constructs labels for deployment +// Uses provided labels or generates default labels from Yanet CR +func (b *DeploymentBuilder) buildLabels() map[string]string { + if b.labels != nil { + return b.labels + } + // Default labels if not provided + if b.yanet != nil { + return LabelsForYanet(nil, b.yanet, b.componentName) + } + return map[string]string{} +} diff --git a/internal/manifests/controlplane.go b/internal/manifests/controlplane.go index 5b98613..06ab9af 100644 --- a/internal/manifests/controlplane.go +++ b/internal/manifests/controlplane.go @@ -9,7 +9,6 @@ import ( appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -48,7 +47,7 @@ func newControlInitContainers(m *yanetv1alpha1.Yanet) []v1.Container { return initContainers } -// DeploymentForControlplane return dataplane Deployment object +// DeploymentForControlplane return controlplane Deployment object func DeploymentForControlplane( ctx context.Context, m *yanetv1alpha1.Yanet, config yanetv1alpha1.YanetConfigSpec, @@ -56,105 +55,89 @@ func DeploymentForControlplane( log := log.FromContext(ctx) ok, perTypeOpts := helpers.GetTypeOpts(config.EnabledOpts, m.Spec.Type) if !ok { - log.Info(fmt.Sprintf("typeOpts is not specified for %s", m.Spec.Type)) + log.Info("typeOpts is not specified", "type", m.Spec.Type) } - // Filling in all init containers + + // Build init containers (wait-dataplane + additional) initContainers := newControlInitContainers(m) additionalInitContainers := GetAdditionalInitContainers( - config.AdditionalOpts.InitContainers, // all available initContainers in yanetConfig spec - perTypeOpts.Controlplane.InitContainers, // initContainers enabled for specific type in global config + config.AdditionalOpts.InitContainers, + perTypeOpts.Controlplane.InitContainers, ) initContainers = append(initContainers, additionalInitContainers...) - // start with default config, mount slb config and run reload for l3balancer + // Prepare poststart hook poststart := GetPostStartExec(config.AdditionalOpts.PostStart, perTypeOpts.Controlplane.PostStart) - depName := fmt.Sprintf("controlplane-%s", m.Spec.NodeName) - image := fmt.Sprintf("%s:%s", m.Spec.Controlplane.Image, m.Spec.Tag) + // Determine image and tag + image := m.Spec.Controlplane.Image + tag := m.Spec.Tag if m.Spec.Controlplane.Tag != "" { - image = fmt.Sprintf("%s:%s", m.Spec.Controlplane.Image, m.Spec.Controlplane.Tag) - } - if m.Spec.Registry != "" { - image = fmt.Sprintf("%s/%s", m.Spec.Registry, image) + tag = m.Spec.Controlplane.Tag } - // Creating deployment based on previously created structures + + // Calculate replicas replicas := int32(0) if m.Spec.Controlplane.Enable { replicas = 1 } - dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Namespace: m.Namespace, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: LabelsForYanet(nil, m, "controlplane"), - }, - Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, - Template: v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Annotations: AnnotationsForYanet(config.AdditionalOpts.Annotations, perTypeOpts.Controlplane.Annotations), - Labels: LabelsForYanet(nil, m, "controlplane"), - }, - Spec: v1.PodSpec{ - HostNetwork: true, - HostIPC: perTypeOpts.Controlplane.HostIpc, - InitContainers: initContainers, - Containers: []v1.Container{ - { - Image: image, - ImagePullPolicy: v1.PullIfNotPresent, - Name: "controlplane", - Command: []string{"/usr/bin/yanet-controlplane"}, - Args: []string{ - "-c", - "/etc/yanet/controlplane.conf", - }, - Resources: GetResources( - ctx, - m.Spec.NodeName, - perTypeOpts.Controlplane.Resources, - nodes, - false, - ), - Lifecycle: &v1.Lifecycle{ - PostStart: &v1.LifecycleHandler{ - Exec: &v1.ExecAction{Command: poststart}, - }, - }, - VolumeMounts: []v1.VolumeMount{ - {Name: "etc-yanet", MountPath: "/etc/yanet"}, - {Name: "run-yanet", MountPath: "/run/yanet"}, - {Name: "run-bird", MountPath: "/run/bird"}, - {Name: "spool-yanet-agent", MountPath: "/var/spool/yanet-agent"}, - }, - TerminationMessagePath: "/dev/stdout", - TerminationMessagePolicy: "File", - SecurityContext: &v1.SecurityContext{ - Privileged: &perTypeOpts.Controlplane.Privileged, - Capabilities: &v1.Capabilities{ - Add: []v1.Capability{ - "NET_ADMIN", - "NET_BIND_SERVICE", - "IPC_LOCK", - "SYS_MODULE", - "SYS_NICE", - }, - }, - }, - }, - }, - NodeSelector: map[string]string{ - "kubernetes.io/hostname": m.Spec.NodeName, - }, - Tolerations: TolerationsForYanet(), - Volumes: GetVolumes([]string{"/etc/yanet", "/run/yanet", "/run/bird", "/var/spool/yanet-agent"}), - }, + + // Build security context + privileged := perTypeOpts.Controlplane.Privileged + securityCtx := &v1.SecurityContext{ + Privileged: &privileged, + Capabilities: &v1.Capabilities{ + Add: []v1.Capability{ + "NET_ADMIN", + "NET_BIND_SERVICE", + "IPC_LOCK", + "SYS_MODULE", + "SYS_NICE", }, }, } - return dep + + // Build deployment using builder pattern + return NewDeploymentBuilder(). + WithYanet(m). + WithName(fmt.Sprintf("controlplane-%s", m.Spec.NodeName)). + WithComponentName("controlplane"). + WithReplicas(replicas). + WithImage(m.Spec.Registry, image, tag). + WithHostNetwork(true). + WithHostIPC(perTypeOpts.Controlplane.HostIpc). + WithAnnotations(AnnotationsForYanet( + config.AdditionalOpts.Annotations, + perTypeOpts.Controlplane.Annotations, + )). + WithNodeSelector(map[string]string{ + "kubernetes.io/hostname": m.Spec.NodeName, + }). + WithTolerations(TolerationsForYanet()). + WithContainer("controlplane", + []string{"/usr/bin/yanet-controlplane"}, + []string{"-c", "/etc/yanet/controlplane.conf"}, + ). + WithVolumeMounts([]v1.VolumeMount{ + {Name: "etc-yanet", MountPath: "/etc/yanet"}, + {Name: "run-yanet", MountPath: "/run/yanet"}, + {Name: "run-bird", MountPath: "/run/bird"}, + {Name: "spool-yanet-agent", MountPath: "/var/spool/yanet-agent"}, + }). + WithResources(GetResources( + ctx, + m.Spec.NodeName, + perTypeOpts.Controlplane.Resources, + nodes, + false, + )). + WithSecurityContext(securityCtx). + WithLifecycle(&v1.Lifecycle{ + PostStart: &v1.LifecycleHandler{ + Exec: &v1.ExecAction{Command: poststart}, + }, + }). + WithInitContainers(initContainers). + WithVolumes(GetVolumes([]string{"/etc/yanet", "/run/yanet", "/run/bird", "/var/spool/yanet-agent"})). + Build() } diff --git a/internal/manifests/controlplane_test.go b/internal/manifests/controlplane_test.go new file mode 100644 index 0000000..90fe129 --- /dev/null +++ b/internal/manifests/controlplane_test.go @@ -0,0 +1,329 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manifests + +import ( + "context" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestDeploymentForControlplane verifies controlplane deployment generation +func TestDeploymentForControlplane(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", // Important: must match config.EnabledOpts.Release + Tag: "1.0.0", + Registry: "docker.io/test", + Controlplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-controlplane", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{ + EnabledOpts: yanetv1alpha1.EnabledOpts{ + Release: yanetv1alpha1.DepOpts{ + Controlplane: yanetv1alpha1.OptsNames{ + Annotations: []string{"checkpointer"}, + HostIpc: true, + Privileged: false, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("6"), + "memory": resource.MustParse("128Gi"), + }, + Requests: v1.ResourceList{ + "cpu": resource.MustParse("1"), + "memory": resource.MustParse("16Gi"), + }, + }, + }, + }, + }, + AdditionalOpts: yanetv1alpha1.AdditionalOpts{ + Annotations: []yanetv1alpha1.NamedAnnotations{ + { + Name: "checkpointer", + Annotations: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + "checkpointer.ydb.tech/manual-recovery": "true", + }, + }, + }, + }, + } + + nodes := v1.NodeList{} + + dep := DeploymentForControlplane(context.Background(), yanet, config, nodes) + + // Test 1: Deployment name + expectedName := "controlplane-test-node" + if dep.Name != expectedName { + t.Errorf("expected name %q, got %q", expectedName, dep.Name) + } + + // Test 2: Replicas + if *dep.Spec.Replicas != 1 { + t.Errorf("expected 1 replica, got %d", *dep.Spec.Replicas) + } + + // Test 3: Container + if len(dep.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(dep.Spec.Template.Spec.Containers)) + } + + container := dep.Spec.Template.Spec.Containers[0] + if container.Name != "controlplane" { + t.Errorf("expected container name 'controlplane', got %q", container.Name) + } + + // Test 4: Image + expectedImage := "docker.io/test/yanet-controlplane:1.0.0" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } + + // Test 5: Command and Args (ΠΈΠ· production test-case.txt строки 685-686) + expectedCommand := []string{"/usr/bin/yanet-controlplane"} + if len(container.Command) != 1 || container.Command[0] != expectedCommand[0] { + t.Errorf("expected command %v, got %v", expectedCommand, container.Command) + } + + expectedArgs := []string{"-c", "/etc/yanet/controlplane.conf"} + if len(container.Args) != 2 || container.Args[0] != "-c" || container.Args[1] != "/etc/yanet/controlplane.conf" { + t.Errorf("expected args %v, got %v", expectedArgs, container.Args) + } + + // Test 6: HostIPC (ΠΈΠ· production test-case.txt строка 725) + if !dep.Spec.Template.Spec.HostIPC { + t.Error("expected HostIPC to be true") + } + + // Test 7: Privileged (ΠΈΠ· production test-case.txt строка 712) + if container.SecurityContext == nil { + t.Fatal("expected SecurityContext to be set") + } + if *container.SecurityContext.Privileged { + t.Error("expected Privileged to be false for controlplane") + } + + // Test 8: Resources (ΠΈΠ· production test-case.txt строки 697-703) + cpuLimit := container.Resources.Limits["cpu"] + expectedCPU := resource.MustParse("6") + if !cpuLimit.Equal(expectedCPU) { + t.Errorf("expected CPU limit %v, got %v", expectedCPU, cpuLimit) + } + + memLimit := container.Resources.Limits["memory"] + expectedMem := resource.MustParse("128Gi") + if !memLimit.Equal(expectedMem) { + t.Errorf("expected memory limit %v, got %v", expectedMem, memLimit) + } + + // Test 9: InitContainer wait-dataplane (ΠΈΠ· production test-case.txt строки 728-745) + if len(dep.Spec.Template.Spec.InitContainers) < 1 { + t.Fatal("expected at least 1 init container (wait-dataplane)") + } + + initContainer := dep.Spec.Template.Spec.InitContainers[0] + if initContainer.Name != "wait-dataplane" { + t.Errorf("expected init container name 'wait-dataplane', got %q", initContainer.Name) + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Ρ‡Ρ‚ΠΎ init container ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ Ρ‚ΠΎΡ‚ ΠΆΠ΅ image Ρ‡Ρ‚ΠΎ ΠΈ controlplane + expectedInitImage := "docker.io/test/yanet-controlplane:1.0.0" + if initContainer.Image != expectedInitImage { + t.Errorf("expected init container image %q, got %q", expectedInitImage, initContainer.Image) + } + + // Test 10: Volumes (ΠΈΠ· production test-case.txt строки 759-775) + // Controlplane Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΠΌΠ΅Ρ‚ΡŒ 4 volume: etc-yanet, run-yanet, run-bird, spool-yanet-agent + expectedVolumes := []string{"etc-yanet", "run-yanet", "run-bird", "spool-yanet-agent"} + if len(dep.Spec.Template.Spec.Volumes) != len(expectedVolumes) { + t.Errorf("expected %d volumes, got %d", len(expectedVolumes), len(dep.Spec.Template.Spec.Volumes)) + } + + volumeNames := make(map[string]bool) + for _, vol := range dep.Spec.Template.Spec.Volumes { + volumeNames[vol.Name] = true + } + + for _, expectedVol := range expectedVolumes { + if !volumeNames[expectedVol] { + t.Errorf("expected volume %q not found", expectedVol) + } + } + + // Test 11: VolumeMounts (ΠΈΠ· production test-case.txt строки 715-723) + expectedMounts := 4 + if len(container.VolumeMounts) != expectedMounts { + t.Errorf("expected %d volume mounts, got %d", expectedMounts, len(container.VolumeMounts)) + } + + // Test 12: NodeSelector (ΠΈΠ· production test-case.txt строка 747) + if dep.Spec.Template.Spec.NodeSelector["kubernetes.io/hostname"] != "test-node" { + t.Error("expected node selector to match node name") + } + + // Test 13: Annotations (ΠΈΠ· production test-case.txt строки 671-673) + annotations := dep.Spec.Template.ObjectMeta.Annotations + if annotations["checkpointer.ydb.tech/checkpoint"] != "true" { + t.Error("expected checkpointer annotation to be set") + } + if annotations["checkpointer.ydb.tech/manual-recovery"] != "true" { + t.Error("expected manual-recovery annotation to be set") + } +} + +// TestDeploymentForControlplane_Disabled verifies deployment with replicas=0 when Enable=false +func TestDeploymentForControlplane_Disabled(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Controlplane: yanetv1alpha1.Dep{ + Enable: false, // Disabled + Image: "yanet-controlplane", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForControlplane(context.Background(), yanet, config, nodes) + + if *dep.Spec.Replicas != 0 { + t.Errorf("expected 0 replicas for disabled deployment, got %d", *dep.Spec.Replicas) + } +} + +// TestDeploymentForControlplane_CustomTag verifies custom tag overrides global tag +func TestDeploymentForControlplane_CustomTag(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Tag: "1.0.0", + Controlplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-controlplane", + Tag: "2.0.0", // Custom tag overrides global tag + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForControlplane(context.Background(), yanet, config, nodes) + + container := dep.Spec.Template.Spec.Containers[0] + expectedImage := "yanet-controlplane:2.0.0" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } + + // Π’Π°ΠΊΠΆΠ΅ провСряСм, Ρ‡Ρ‚ΠΎ init container ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ Ρ‚ΠΎΡ‚ ΠΆΠ΅ custom tag + initContainer := dep.Spec.Template.Spec.InitContainers[0] + // Init container ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ image ΠΈΠ· yanet.Spec.Controlplane, Π½ΠΎ с global tag + // (см. controlplane.go строки 16-19) + expectedInitImage := "yanet-controlplane:1.0.0" + if initContainer.Image != expectedInitImage { + t.Errorf("expected init container image %q, got %q", expectedInitImage, initContainer.Image) + } +} + +// TestDeploymentForControlplane_InitContainerLogic verifies wait-dataplane init container logic +func TestDeploymentForControlplane_InitContainerLogic(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", + Controlplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-controlplane", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForControlplane(context.Background(), yanet, config, nodes) + + initContainer := dep.Spec.Template.Spec.InitContainers[0] + + // Test: Command should be /bin/sh + if len(initContainer.Command) != 1 || initContainer.Command[0] != "/bin/sh" { + t.Errorf("expected command [/bin/sh], got %v", initContainer.Command) + } + + // Test: Args should contain logic to wait for dataplane.sock + if len(initContainer.Args) < 2 { + t.Fatal("expected at least 2 args (-c and script)") + } + + if initContainer.Args[0] != "-c" { + t.Errorf("expected first arg '-c', got %q", initContainer.Args[0]) + } + + script := initContainer.Args[1] + if !contains(script, "dataplane.sock") { + t.Error("expected init container script to wait for dataplane.sock") + } + + if !contains(script, "/run/yanet/dataplane.sock") { + t.Error("expected init container script to check /run/yanet/dataplane.sock") + } + + // Test: VolumeMounts should include run-yanet + hasRunYanet := false + for _, mount := range initContainer.VolumeMounts { + if mount.Name == "run-yanet" && mount.MountPath == "/run/yanet" { + hasRunYanet = true + break + } + } + if !hasRunYanet { + t.Error("expected init container to have run-yanet volume mount") + } +} + +// Helper function to check substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/manifests/dataplane.go b/internal/manifests/dataplane.go index 111cc0b..9394252 100644 --- a/internal/manifests/dataplane.go +++ b/internal/manifests/dataplane.go @@ -8,7 +8,6 @@ import ( "github.com/yanet-platform/yanet-operator/internal/helpers" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -21,101 +20,86 @@ func DeploymentForDataplane( log := log.FromContext(ctx) ok, perTypeOpts := helpers.GetTypeOpts(config.EnabledOpts, m.Spec.Type) if !ok { - log.Info(fmt.Sprintf("typeOpts is not specified for %s", m.Spec.Type)) + log.Info("typeOpts is not specified", "type", m.Spec.Type) } - // Filling in all init containers + // Prepare init containers initContainers := GetAdditionalInitContainers( - config.AdditionalOpts.InitContainers, // all available initContainers in yanetConfig spec - perTypeOpts.Dataplain.InitContainers, // initContainers enabled for specific type in global config + config.AdditionalOpts.InitContainers, + perTypeOpts.Dataplain.InitContainers, ) + // Prepare poststart hook poststart := GetPostStartExec(config.AdditionalOpts.PostStart, perTypeOpts.Dataplain.PostStart) - // Creating deployment based on previously created structures - depName := fmt.Sprintf("dataplane-%s", m.Spec.NodeName) - image := fmt.Sprintf("%s:%s", m.Spec.Dataplane.Image, m.Spec.Tag) + // Determine image and tag + image := m.Spec.Dataplane.Image + tag := m.Spec.Tag if m.Spec.Dataplane.Tag != "" { - image = fmt.Sprintf("%s:%s", m.Spec.Dataplane.Image, m.Spec.Dataplane.Tag) - } - if m.Spec.Registry != "" { - image = fmt.Sprintf("%s/%s", m.Spec.Registry, image) + tag = m.Spec.Dataplane.Tag } + + // Calculate replicas replicas := int32(0) if m.Spec.Dataplane.Enable { replicas = 1 } - dep := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Namespace: m.Namespace, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: LabelsForYanet(nil, m, "dataplane"), - }, - Strategy: appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}, - Template: v1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Name: depName, - Annotations: AnnotationsForYanet(config.AdditionalOpts.Annotations, perTypeOpts.Dataplain.Annotations), - Labels: LabelsForYanet(nil, m, "dataplane"), - }, - Spec: v1.PodSpec{ - HostNetwork: true, - HostIPC: perTypeOpts.Dataplain.HostIpc, - InitContainers: initContainers, - Containers: []v1.Container{ - { - Image: image, - ImagePullPolicy: v1.PullIfNotPresent, - Name: "dataplane", - Command: []string{"/usr/bin/yanet-dataplane"}, - Args: []string{ - "-c", "/etc/yanet/dataplane.conf", - }, - Resources: GetResources( - ctx, - m.Spec.NodeName, - perTypeOpts.Dataplain.Resources, - nodes, - true, - ), - Lifecycle: &v1.Lifecycle{ - PostStart: &v1.LifecycleHandler{ - Exec: &v1.ExecAction{Command: poststart}, - }, - }, - VolumeMounts: []v1.VolumeMount{ - {Name: "hugepage", MountPath: "/dev/hugepages"}, - {Name: "etc-yanet", MountPath: "/etc/yanet"}, - {Name: "run-yanet", MountPath: "/run/yanet"}, - }, - TerminationMessagePath: "/dev/stdout", - TerminationMessagePolicy: "File", - SecurityContext: &v1.SecurityContext{ - Privileged: &perTypeOpts.Dataplain.Privileged, - Capabilities: &v1.Capabilities{ - Add: []v1.Capability{ - "NET_ADMIN", - "NET_BIND_SERVICE", - "IPC_LOCK", - "SYS_MODULE", - "SYS_NICE", - }, - }, - }, - }, - }, - NodeSelector: map[string]string{ - "kubernetes.io/hostname": m.Spec.NodeName, - }, - Tolerations: TolerationsForYanet(), - Volumes: GetVolumes([]string{"/dev/hugepages", "/etc/yanet", "/run/yanet"}), - }, + + // Build security context + privileged := perTypeOpts.Dataplain.Privileged + securityCtx := &v1.SecurityContext{ + Privileged: &privileged, + Capabilities: &v1.Capabilities{ + Add: []v1.Capability{ + "NET_ADMIN", + "NET_BIND_SERVICE", + "IPC_LOCK", + "SYS_MODULE", + "SYS_NICE", }, }, } - return dep + + // Build deployment using builder pattern + return NewDeploymentBuilder(). + WithYanet(m). + WithName(fmt.Sprintf("dataplane-%s", m.Spec.NodeName)). + WithComponentName("dataplane"). + WithReplicas(replicas). + WithImage(m.Spec.Registry, image, tag). + WithHostNetwork(true). + WithHostIPC(perTypeOpts.Dataplain.HostIpc). + WithAnnotations(AnnotationsForYanet( + config.AdditionalOpts.Annotations, + perTypeOpts.Dataplain.Annotations, + )). + WithNodeSelector(map[string]string{ + "kubernetes.io/hostname": m.Spec.NodeName, + }). + WithTolerations(TolerationsForYanet()). + WithContainer("dataplane", + []string{"/usr/bin/yanet-dataplane"}, + []string{"-c", "/etc/yanet/dataplane.conf"}, + ). + WithVolumeMounts([]v1.VolumeMount{ + {Name: "hugepage", MountPath: "/dev/hugepages"}, + {Name: "etc-yanet", MountPath: "/etc/yanet"}, + {Name: "run-yanet", MountPath: "/run/yanet"}, + }). + WithResources(GetResources( + ctx, + m.Spec.NodeName, + perTypeOpts.Dataplain.Resources, + nodes, + true, + )). + WithSecurityContext(securityCtx). + WithLifecycle(&v1.Lifecycle{ + PostStart: &v1.LifecycleHandler{ + Exec: &v1.ExecAction{Command: poststart}, + }, + }). + WithInitContainers(initContainers). + WithVolumes(GetVolumes([]string{"/dev/hugepages", "/etc/yanet", "/run/yanet"})). + Build() } diff --git a/internal/manifests/dataplane_test.go b/internal/manifests/dataplane_test.go new file mode 100644 index 0000000..394288c --- /dev/null +++ b/internal/manifests/dataplane_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manifests + +import ( + "context" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDeploymentForDataplane(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-yanet", + Namespace: "default", + }, + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Type: "release", // Important: must match config.EnabledOpts.Release + Tag: "1.0.0", + Registry: "docker.io/test", + Dataplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-dataplane", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{ + EnabledOpts: yanetv1alpha1.EnabledOpts{ + Release: yanetv1alpha1.DepOpts{ + Dataplain: yanetv1alpha1.OptsNames{ + HostIpc: true, + Privileged: true, + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "memory": resource.MustParse("32Gi"), + }, + }, + }, + }, + }, + } + + nodes := v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + "hugepages-1Gi": resource.MustParse("8Gi"), + }, + }, + }, + }, + } + + dep := DeploymentForDataplane(context.Background(), yanet, config, nodes) + + // Test: Deployment name + expectedName := "dataplane-test-node" + if dep.Name != expectedName { + t.Errorf("expected name %q, got %q", expectedName, dep.Name) + } + + // Test: Replicas + if *dep.Spec.Replicas != 1 { + t.Errorf("expected 1 replica, got %d", *dep.Spec.Replicas) + } + + // Test: Container + if len(dep.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(dep.Spec.Template.Spec.Containers)) + } + + container := dep.Spec.Template.Spec.Containers[0] + if container.Name != "dataplane" { + t.Errorf("expected container name 'dataplane', got %q", container.Name) + } + + expectedImage := "docker.io/test/yanet-dataplane:1.0.0" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } + + // Test: HostIPC + if !dep.Spec.Template.Spec.HostIPC { + t.Error("expected HostIPC to be true") + } + + // Test: Privileged + if container.SecurityContext == nil || !*container.SecurityContext.Privileged { + t.Error("expected Privileged to be true") + } + + // Test: Hugepages + hugepages := container.Resources.Limits["hugepages-1Gi"] + expectedHugepages := resource.MustParse("8Gi") + if !hugepages.Equal(expectedHugepages) { + t.Errorf("expected hugepages %v, got %v", expectedHugepages, hugepages) + } + + // Test: Node selector + if dep.Spec.Template.Spec.NodeSelector["kubernetes.io/hostname"] != "test-node" { + t.Error("expected node selector to match node name") + } +} + +func TestDeploymentForDataplane_Disabled(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Dataplane: yanetv1alpha1.Dep{ + Enable: false, // Disabled + Image: "yanet-dataplane", + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForDataplane(context.Background(), yanet, config, nodes) + + if *dep.Spec.Replicas != 0 { + t.Errorf("expected 0 replicas for disabled deployment, got %d", *dep.Spec.Replicas) + } +} + +func TestDeploymentForDataplane_CustomTag(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + Tag: "1.0.0", + Dataplane: yanetv1alpha1.Dep{ + Enable: true, + Image: "yanet-dataplane", + Tag: "2.0.0", // Custom tag overrides global tag + }, + }, + } + + config := yanetv1alpha1.YanetConfigSpec{} + nodes := v1.NodeList{} + + dep := DeploymentForDataplane(context.Background(), yanet, config, nodes) + + container := dep.Spec.Template.Spec.Containers[0] + expectedImage := "yanet-dataplane:2.0.0" + if container.Image != expectedImage { + t.Errorf("expected image %q, got %q", expectedImage, container.Image) + } +} diff --git a/internal/manifests/helpers.go b/internal/manifests/helpers.go index 4fe9f72..0decd97 100644 --- a/internal/manifests/helpers.go +++ b/internal/manifests/helpers.go @@ -15,10 +15,10 @@ import ( // GetVolumes generate Volumes for deployment // TODO: add more type -func GetVolumes(HostpathOrCreate []string) []v1.Volume { +func GetVolumes(hostpathOrCreate []string) []v1.Volume { Volumes := []v1.Volume{} hostPathDirectoryOrCreate := v1.HostPathDirectoryOrCreate - for _, path := range HostpathOrCreate { + for _, path := range hostpathOrCreate { if strings.Contains(path, "hugepages") { Volumes = append(Volumes, v1.Volume{ Name: "hugepage", diff --git a/internal/manifests/helpers_test.go b/internal/manifests/helpers_test.go new file mode 100644 index 0000000..155fc36 --- /dev/null +++ b/internal/manifests/helpers_test.go @@ -0,0 +1,427 @@ +/* +Copyright 2023-2026 YANDEX LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manifests + +import ( + "context" + "reflect" + "testing" + + yanetv1alpha1 "github.com/yanet-platform/yanet-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestGetVolumes verifies volume generation for different path types +func TestGetVolumes(t *testing.T) { + tests := []struct { + name string + paths []string + expectedCount int + checkVolume func([]v1.Volume) error + }{ + { + name: "empty paths", + paths: []string{}, + expectedCount: 0, + }, + { + name: "single hostpath", + paths: []string{"/etc/yanet"}, + expectedCount: 1, + checkVolume: func(volumes []v1.Volume) error { + if volumes[0].Name != "etc-yanet" { + t.Errorf("expected volume name 'etc-yanet', got %q", volumes[0].Name) + } + if volumes[0].VolumeSource.HostPath == nil { + t.Error("expected HostPath volume source") + } + if volumes[0].VolumeSource.HostPath.Path != "/etc/yanet" { + t.Errorf("expected path '/etc/yanet', got %q", volumes[0].VolumeSource.HostPath.Path) + } + return nil + }, + }, + { + name: "hugepages volume", + paths: []string{"/dev/hugepages"}, + expectedCount: 1, + checkVolume: func(volumes []v1.Volume) error { + if volumes[0].Name != "hugepage" { + t.Errorf("expected volume name 'hugepage', got %q", volumes[0].Name) + } + if volumes[0].VolumeSource.EmptyDir == nil { + t.Fatal("expected EmptyDir volume source for hugepages") + } + if volumes[0].VolumeSource.EmptyDir.Medium != v1.StorageMediumHugePages { + t.Error("expected HugePages medium") + } + return nil + }, + }, + { + name: "multiple volumes", + paths: []string{"/etc/yanet", "/run/yanet", "/run/bird"}, + expectedCount: 3, + }, + { + name: "mixed hugepages and hostpath", + paths: []string{"/dev/hugepages", "/etc/yanet", "/run/yanet"}, + expectedCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + volumes := GetVolumes(tt.paths) + + if len(volumes) != tt.expectedCount { + t.Errorf("expected %d volumes, got %d", tt.expectedCount, len(volumes)) + } + + if tt.checkVolume != nil { + tt.checkVolume(volumes) + } + }) + } +} + +// TestLabelsForYanet verifies label generation +func TestLabelsForYanet(t *testing.T) { + yanet := &yanetv1alpha1.Yanet{ + Spec: yanetv1alpha1.YanetSpec{ + NodeName: "test-node", + }, + } + + // Test: Basic labels without additions + labels := LabelsForYanet(nil, yanet, "dataplane") + + expectedLabels := map[string]string{ + "app": "dataplane", + "app.kubernetes.io/name": "dataplane", + "app.kubernetes.io/created-by": "yanet-operator", + "topology-location-host": "test-node", + } + + if !reflect.DeepEqual(labels, expectedLabels) { + t.Errorf("expected labels %v, got %v", expectedLabels, labels) + } + + // Test: Labels with additions + additions := map[string]string{ + "custom-label": "custom-value", + "app": "should-be-overridden", // This will override base label + } + + labelsWithAdditions := LabelsForYanet(additions, yanet, "controlplane") + + if labelsWithAdditions["custom-label"] != "custom-value" { + t.Error("expected custom-label to be added") + } + + // maps.Copy overwrites existing keys + if labelsWithAdditions["app"] != "should-be-overridden" { + t.Error("expected app label to be overridden by additions") + } +} + +// TestAnnotationsForYanet verifies annotation filtering and merging +func TestAnnotationsForYanet(t *testing.T) { + annotations := []yanetv1alpha1.NamedAnnotations{ + { + Name: "checkpointer", + Annotations: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + }, + }, + { + Name: "telegraf", + Annotations: map[string]string{ + "telegraf.influxdata.com/ports": "8080", + }, + }, + } + + tests := []struct { + name string + names []string + expected map[string]string + }{ + { + name: "no annotations selected", + names: []string{}, + expected: nil, + }, + { + name: "single annotation", + names: []string{"checkpointer"}, + expected: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + }, + }, + { + name: "multiple annotations", + names: []string{"checkpointer", "telegraf"}, + expected: map[string]string{ + "checkpointer.ydb.tech/checkpoint": "true", + "telegraf.influxdata.com/ports": "8080", + }, + }, + { + name: "non-existent annotation", + names: []string{"non-existent"}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := AnnotationsForYanet(annotations, tt.names) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// TestTolerationsForYanet verifies toleration generation +func TestTolerationsForYanet(t *testing.T) { + tolerations := TolerationsForYanet() + + // Test: Should have 3 tolerations + if len(tolerations) != 3 { + t.Errorf("expected 3 tolerations, got %d", len(tolerations)) + } + + // Test: CriticalAddonsOnly toleration + hasCriticalAddons := false + for _, tol := range tolerations { + if tol.Key == "CriticalAddonsOnly" && tol.Effect == v1.TaintEffectNoSchedule { + hasCriticalAddons = true + break + } + } + if !hasCriticalAddons { + t.Error("expected CriticalAddonsOnly toleration") + } + + // Test: NoSchedule toleration with Exists operator + hasNoScheduleExists := false + for _, tol := range tolerations { + if tol.Operator == v1.TolerationOpExists && tol.Effect == v1.TaintEffectNoSchedule { + hasNoScheduleExists = true + break + } + } + if !hasNoScheduleExists { + t.Error("expected NoSchedule toleration with Exists operator") + } + + // Test: NoExecute toleration with Exists operator + hasNoExecuteExists := false + for _, tol := range tolerations { + if tol.Operator == v1.TolerationOpExists && tol.Effect == v1.TaintEffectNoExecute { + hasNoExecuteExists = true + break + } + } + if !hasNoExecuteExists { + t.Error("expected NoExecute toleration with Exists operator") + } +} + +// TestGetResources verifies resource generation with hugepages +func TestGetResources(t *testing.T) { + ctx := context.Background() + + // Test: Without hugepages + resources := v1.ResourceRequirements{ + Limits: v1.ResourceList{ + "cpu": resource.MustParse("4"), + }, + } + + result := GetResources(ctx, "test-node", resources, v1.NodeList{}, false) + + if !reflect.DeepEqual(result, resources) { + t.Error("expected resources to be unchanged when hugepages disabled") + } + + // Test: With hugepages + nodes := v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + "hugepages-1Gi": resource.MustParse("16Gi"), + }, + }, + }, + }, + } + + resourcesWithHugepages := GetResources(ctx, "test-node", resources, nodes, true) + + // Should add hugepages limit from node capacity + hugepages := resourcesWithHugepages.Limits["hugepages-1Gi"] + expectedHugepages := resource.MustParse("16Gi") + if !hugepages.Equal(expectedHugepages) { + t.Errorf("expected hugepages %v, got %v", expectedHugepages, hugepages) + } + + // Should add default memory limit + memory := resourcesWithHugepages.Limits["memory"] + expectedMemory := resource.MustParse("8Gi") + if !memory.Equal(expectedMemory) { + t.Errorf("expected memory %v, got %v", expectedMemory, memory) + } + + // Original CPU limit should be preserved + cpu := resourcesWithHugepages.Limits["cpu"] + expectedCPU := resource.MustParse("4") + if !cpu.Equal(expectedCPU) { + t.Errorf("expected CPU %v, got %v", expectedCPU, cpu) + } +} + +// TestGetPostStartExec verifies poststart exec command generation +func TestGetPostStartExec(t *testing.T) { + execs := []yanetv1alpha1.NamedLifecycleHandler{ + { + Name: "reloader", + Exec: "sleep 60; /usr/bin/yanet-cli reload", + }, + { + Name: "netconfig", + Exec: "ip link add ...", + }, + } + + tests := []struct { + name string + names yanetv1alpha1.LifecycleHandler + expected []string + }{ + { + name: "no execs selected", + names: yanetv1alpha1.LifecycleHandler{Exec: []string{}}, + expected: []string{ + "/bin/bash", + "-c", + "echo starting...", + }, + }, + { + name: "single exec", + names: yanetv1alpha1.LifecycleHandler{Exec: []string{"reloader"}}, + expected: []string{ + "/bin/bash", + "-c", + "echo starting...;sleep 60; /usr/bin/yanet-cli reload", + }, + }, + { + name: "multiple execs", + names: yanetv1alpha1.LifecycleHandler{Exec: []string{"reloader", "netconfig"}}, + expected: []string{ + "/bin/bash", + "-c", + "echo starting...;sleep 60; /usr/bin/yanet-cli reload;ip link add ...", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetPostStartExec(execs, tt.names) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// TestGetAdditionalInitContainers verifies init container filtering +func TestGetAdditionalInitContainers(t *testing.T) { + initContainers := []v1.Container{ + { + Name: "init1", + Image: "image1", + }, + { + Name: "init2", + Image: "image2", + }, + { + Name: "init3", + Image: "image3", + }, + } + + tests := []struct { + name string + names []string + expectedCount int + expectedNames []string + }{ + { + name: "no init containers selected", + names: []string{}, + expectedCount: 0, + }, + { + name: "single init container", + names: []string{"init1"}, + expectedCount: 1, + expectedNames: []string{"init1"}, + }, + { + name: "multiple init containers", + names: []string{"init1", "init3"}, + expectedCount: 2, + expectedNames: []string{"init1", "init3"}, + }, + { + name: "non-existent init container", + names: []string{"non-existent"}, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetAdditionalInitContainers(initContainers, tt.names) + + if len(result) != tt.expectedCount { + t.Errorf("expected %d init containers, got %d", tt.expectedCount, len(result)) + } + + if tt.expectedNames != nil { + for i, name := range tt.expectedNames { + if result[i].Name != name { + t.Errorf("expected init container name %q at index %d, got %q", name, i, result[i].Name) + } + } + } + }) + } +}