├── .envrc ├── .gitattributes ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── funding.yml ├── release-drafter.yml └── workflows │ ├── build.yml │ ├── chore.yml │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── .pre-commit-config.yaml ├── .taplo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── chart ├── Chart.yaml ├── README.md ├── README.md.gotmpl ├── templates │ ├── _config.tpl │ ├── _helpers.tpl │ ├── config.yaml │ ├── deployment.yaml │ ├── issuer.yaml │ ├── monitor.yaml │ └── rbac.yaml └── values.yaml ├── cmd └── main.go ├── codecov.yml ├── dev ├── config.yaml └── manifests │ ├── ca-secret.yaml │ ├── ingress.yaml │ └── tls-issuer.yaml ├── go.mod ├── go.sum ├── internal ├── config │ └── v1 │ │ └── config.go ├── controllers │ ├── ingressroute.go │ ├── ingressroute_test.go │ ├── utils.go │ └── utils_test.go ├── ext │ ├── optionals.go │ ├── optionals_test.go │ ├── slices.go │ └── slices_test.go ├── integrations │ ├── certmanager.go │ ├── certmanager_test.go │ ├── externaldns.go │ ├── externaldns_test.go │ ├── interface.go │ ├── utils.go │ └── utils_test.go ├── k8s │ ├── delete.go │ ├── delete_test.go │ ├── enqueue.go │ └── enqueue_test.go ├── k8tests │ ├── client.go │ ├── dummies.go │ └── namespace.go └── switchboard │ ├── hosts.go │ ├── hosts_test.go │ ├── selector.go │ ├── selector_test.go │ ├── targets.go │ └── targets_test.go ├── pixi.lock ├── pixi.toml └── tests ├── config ├── kind.yaml ├── registry.yaml └── switchboard.yaml ├── deployment.bats ├── lib └── helpers.bash ├── resources └── ingress.yaml └── scripts └── connect-registry.sh /.envrc: -------------------------------------------------------------------------------- 1 | watch_file pixi.toml pixi.lock 2 | eval "$(pixi shell-hook)" 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # SCM syntax highlighting & preventing 3-way merges 2 | pixi.lock merge=binary linguist-language=YAML linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @borchero 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | 4 | 5 | # Changes 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Update GitHub actions 4 | - directory: / 5 | package-ecosystem: github-actions 6 | schedule: 7 | interval: monthly 8 | groups: 9 | ci-dependencies: 10 | patterns: 11 | - "*" 12 | commit-message: 13 | prefix: ci 14 | labels: 15 | - dependencies 16 | # Update Go dependencies 17 | - directory: / 18 | package-ecosystem: gomod 19 | schedule: 20 | interval: monthly 21 | groups: 22 | go-dependencies: 23 | patterns: 24 | - "*" 25 | commit-message: 26 | prefix: build(go) 27 | labels: 28 | - dependencies 29 | # Update Docker base images 30 | - directory: / 31 | package-ecosystem: docker 32 | schedule: 33 | interval: monthly 34 | groups: 35 | docker-dependencies: 36 | patterns: 37 | - "*" 38 | commit-message: 39 | prefix: build(docker) 40 | labels: 41 | - dependencies 42 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: borchero 2 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------- LABELS ------------------------------------------- # 2 | autolabeler: 3 | # Conventional Commit Types (https://github.com/commitizen/conventional-commit-types) 4 | - label: build 5 | title: 6 | - '/^build(\(.*\))?(\!)?\:/' 7 | - label: chore 8 | title: 9 | - '/^chore(\(.*\))?(\!)?\:/' 10 | - label: ci 11 | title: 12 | - '/^ci(\(.*\))?(\!)?\:/' 13 | - label: documentation 14 | title: 15 | - '/^docs(\(.*\))?(\!)?\:/' 16 | - label: enhancement 17 | title: 18 | - '/^feat(\(.*\))?(\!)?\:/' 19 | - label: fix 20 | title: 21 | - '/^fix(\(.*\))?(\!)?\:/' 22 | - label: performance 23 | title: 24 | - '/^perf(\(.*\))?(\!)?\:/' 25 | - label: refactor 26 | title: 27 | - '/^refactor(\(.*\))?(\!)?\:/' 28 | - label: revert 29 | title: 30 | - '/^revert(\(.*\))?(\!)?\:/' 31 | - label: style 32 | title: 33 | - '/^style(\(.*\))?(\!)?\:/' 34 | - label: test 35 | title: 36 | - '/^test(\(.*\))?(\!)?\:/' 37 | # Custom Types 38 | - label: breaking 39 | title: 40 | - '/^[a-z]+(\(.*\))?\!\:/' 41 | # ------------------------------------------ VERSIONING ----------------------------------------- # 42 | version-resolver: 43 | minor: 44 | labels: 45 | - breaking 46 | default: patch 47 | # --------------------------------------- RELEASE TEMPLATE -------------------------------------- # 48 | name-template: "v$RESOLVED_VERSION" 49 | tag-template: "v$RESOLVED_VERSION" 50 | category-template: "### $TITLE" 51 | change-template: "- $TITLE by @$AUTHOR in [#$NUMBER]($URL)" 52 | replacers: 53 | - search: '/- [a-z]+(\!)?\: /g' 54 | replace: "- " 55 | template: | 56 | ## What's Changed 57 | 58 | $CHANGES 59 | 60 | **Full Changelog:** [`$PREVIOUS_TAG...v$RESOLVED_VERSION`](https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION) 61 | categories: 62 | - title: ⚠️ Breaking Changes 63 | labels: 64 | - breaking 65 | - title: ✨ New Features 66 | labels: 67 | - enhancement 68 | - title: 🐞 Bug Fixes 69 | labels: 70 | - fix 71 | - title: 🏎️ Performance Improvements 72 | labels: 73 | - performance 74 | - title: 📚 Documentation 75 | labels: 76 | - documentation 77 | - title: 🏗️ Testing 78 | labels: 79 | - test 80 | - title: ⚙️ Automation 81 | labels: 82 | - ci 83 | - title: 🛠 Builds 84 | labels: 85 | - build 86 | - title: 💎 Code Style 87 | labels: 88 | - style 89 | - title: 📦 Refactorings 90 | labels: 91 | - refactor 92 | - title: ♻️ Chores 93 | labels: 94 | - chore 95 | - title: 🗑 Reverts 96 | labels: 97 | - revert 98 | exclude-labels: 99 | - dependencies 100 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | release: 4 | types: [published] 5 | pull_request: 6 | push: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build-image: 15 | name: Build Image 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Set up Docker buildx 21 | uses: docker/setup-buildx-action@v3 22 | - name: Assemble metadata 23 | id: meta 24 | uses: docker/metadata-action@v5 25 | with: 26 | images: ghcr.io/${{ github.repository }} 27 | # We want to set the following tags: 28 | # - `main` if executed for build on main branch 29 | # - SemVer when running for a release 30 | tags: | 31 | type=ref,enable=${{ github.ref_name == 'main' }},event=branch 32 | type=semver,pattern={{version}} 33 | type=semver,pattern={{major}}.{{minor}} 34 | type=semver,pattern={{major}} 35 | - name: Login to GitHub container registry 36 | # We only need to log in if we want to push to GHCR 37 | if: github.event_name == 'release' || github.event_name == 'push' 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.actor }} 42 | password: ${{ github.token }} 43 | - name: Build multi-platform image 44 | uses: docker/build-push-action@v6 45 | with: 46 | context: . 47 | platforms: linux/amd64,linux/arm64 48 | # Only push when building for a tag or the main branch 49 | push: ${{ github.event_name == 'release' || github.event_name == 'push' }} 50 | tags: ${{ steps.meta.outputs.tags }} 51 | # Only export and upload the image if used for testing 52 | - name: Export image for test platform 53 | if: github.event_name != 'release' 54 | uses: docker/build-push-action@v6 55 | with: 56 | context: . 57 | push: false 58 | outputs: type=docker,dest=/tmp/image.tar 59 | - name: Upload image for testing 60 | uses: actions/upload-artifact@v4 61 | if: github.event_name != 'release' 62 | with: 63 | name: docker-image 64 | path: /tmp/image.tar 65 | 66 | helm-chart: 67 | name: Build Helm Chart 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v4 72 | - name: Setup pixi 73 | uses: prefix-dev/setup-pixi@v0.8.8 74 | with: 75 | environments: default 76 | activate-environment: true 77 | - name: Download Dependencies 78 | run: helm dependency build 79 | working-directory: ./chart 80 | - name: Package Chart 81 | run: | 82 | VERSION=${{ github.event_name == 'release' && github.ref_name || 'v1.0.0' }} 83 | helm package . \ 84 | --app-version ${VERSION#v} \ 85 | --version ${VERSION#v} 86 | working-directory: ./chart 87 | - name: Login to GitHub OCI Registry 88 | if: github.event_name == 'release' 89 | run: | 90 | echo ${{ github.token }} | \ 91 | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin 92 | - name: Push Chart 93 | if: github.event_name == 'release' 94 | run: | 95 | VERSION=${{ github.ref_name }} 96 | helm push switchboard-${VERSION#v}.tgz oci://ghcr.io/${{ github.actor }}/charts 97 | working-directory: ./chart 98 | 99 | e2e-tests: 100 | name: End-to-end Tests 101 | if: github.event_name != 'release' 102 | needs: build-image 103 | runs-on: ubuntu-latest 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | - name: Set up Docker buildx 108 | uses: docker/setup-buildx-action@v3 109 | - name: Download image for testing 110 | uses: actions/download-artifact@v4 111 | with: 112 | name: docker-image 113 | path: /tmp 114 | - name: Setup pixi 115 | uses: prefix-dev/setup-pixi@v0.8.8 116 | with: 117 | environments: default 118 | activate-environment: true 119 | - name: Setup Kind cluster 120 | run: | 121 | pixi run cluster-create 122 | pixi run cluster-setup 123 | - name: Run load balancer controller 124 | run: cloud-provider-kind & 125 | - name: Import Docker image 126 | run: | 127 | IMAGE_ID=$(docker load -i /tmp/image.tar | rev | cut -d' ' -f1 | rev) 128 | docker tag $IMAGE_ID localhost:5001/switchboard:dev 129 | docker push localhost:5001/switchboard:dev 130 | - name: Run tests 131 | run: pixi run test-e2e "localhost:5001/switchboard" "dev" 132 | -------------------------------------------------------------------------------- /.github/workflows/chore.yml: -------------------------------------------------------------------------------- 1 | name: Chore 2 | on: 3 | pull_request_target: 4 | branches: [main] 5 | types: [opened, reopened, edited, synchronize] 6 | push: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check-pr-title: 17 | name: Check PR Title 18 | if: github.event_name == 'pull_request_target' 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | pull-requests: write 23 | steps: 24 | - name: Check valid conventional commit message 25 | id: lint 26 | uses: amannn/action-semantic-pull-request@v5 27 | with: 28 | subjectPattern: ^[A-Z].+[^. ]$ # subject must start with uppercase letter and may not end with a dot/space 29 | scopes: | 30 | docker 31 | go 32 | helm 33 | env: 34 | GITHUB_TOKEN: ${{ github.token }} 35 | - name: Post comment about invalid PR title 36 | if: failure() 37 | uses: marocchino/sticky-pull-request-comment@v2 38 | with: 39 | header: conventional-commit-pr-title 40 | message: | 41 | Thank you for opening this pull request! 👋🏼 42 | 43 | This repository requires pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. 44 | 45 |
Details 46 | 47 | ``` 48 | ${{ steps.lint.outputs.error_message }} 49 | ``` 50 | 51 |
52 | - name: Delete comment about invalid PR title 53 | if: success() 54 | uses: marocchino/sticky-pull-request-comment@v2 55 | with: 56 | header: conventional-commit-pr-title 57 | delete: true 58 | 59 | release-drafter: 60 | name: ${{ github.event_name == 'pull_request_target' && 'Assign Labels' || 'Draft Release' }} 61 | runs-on: ubuntu-latest 62 | permissions: 63 | contents: write 64 | pull-requests: write 65 | steps: 66 | - name: ${{ github.event_name == 'pull_request_target' && 'Assign labels' || 'Update release draft' }} 67 | uses: release-drafter/release-drafter@v6 68 | with: 69 | disable-releaser: ${{ github.event_name == 'pull_request_target' }} 70 | disable-autolabeler: ${{ github.event_name == 'push' }} 71 | env: 72 | GITHUB_TOKEN: ${{ github.token }} 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | pre-commit-checks: 13 | name: Pre-commit Checks 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Setup Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version-file: go.mod 22 | - name: Setup pixi 23 | uses: prefix-dev/setup-pixi@v0.8.8 24 | with: 25 | environments: default 26 | activate-environment: true 27 | - name: Run pre-commit 28 | run: pre-commit run --all-files 29 | 30 | unit-test: 31 | name: Unit Tests 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | - name: Setup Go 37 | uses: actions/setup-go@v5 38 | with: 39 | go-version-file: go.mod 40 | - name: Setup pixi 41 | uses: prefix-dev/setup-pixi@v0.8.8 42 | with: 43 | environments: default 44 | activate-environment: true 45 | - name: Setup Kind cluster 46 | run: | 47 | pixi run cluster-create 48 | pixi run cluster-setup 49 | - name: Run load balancer controller 50 | run: cloud-provider-kind & 51 | - name: Run unit tests 52 | run: pixi run test-coverage 53 | - name: Upload coverage 54 | uses: codecov/codecov-action@v5 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Chart lockfile 2 | Chart.lock 3 | 4 | # pixi 5 | .pixi 6 | 7 | # Created by https://www.toptal.com/developers/gitignore/api/macos,go,helm,visualstudiocode 8 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,go,helm,visualstudiocode 9 | 10 | ### Go ### 11 | # If you prefer the allow list template instead of the deny list, see community template: 12 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 13 | # 14 | # Binaries for programs and plugins 15 | *.exe 16 | *.exe~ 17 | *.dll 18 | *.so 19 | *.dylib 20 | 21 | # Test binary, built with `go test -c` 22 | *.test 23 | 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | 27 | # Dependency directories (remove the comment below to include it) 28 | # vendor/ 29 | 30 | # Go workspace file 31 | go.work 32 | 33 | ### Go Patch ### 34 | /vendor/ 35 | /Godeps/ 36 | 37 | ### Helm ### 38 | # Chart dependencies 39 | **/charts/*.tgz 40 | 41 | ### macOS ### 42 | # General 43 | .DS_Store 44 | .AppleDouble 45 | .LSOverride 46 | 47 | # Icon must end with two \r 48 | Icon 49 | 50 | 51 | # Thumbnails 52 | ._* 53 | 54 | # Files that might appear in the root of a volume 55 | .DocumentRevisions-V100 56 | .fseventsd 57 | .Spotlight-V100 58 | .TemporaryItems 59 | .Trashes 60 | .VolumeIcon.icns 61 | .com.apple.timemachine.donotpresent 62 | 63 | # Directories potentially created on remote AFP share 64 | .AppleDB 65 | .AppleDesktop 66 | Network Trash Folder 67 | Temporary Items 68 | .apdisk 69 | 70 | ### macOS Patch ### 71 | # iCloud generated files 72 | *.icloud 73 | 74 | ### VisualStudioCode ### 75 | .vscode/* 76 | !.vscode/settings.json 77 | !.vscode/tasks.json 78 | !.vscode/launch.json 79 | !.vscode/extensions.json 80 | !.vscode/*.code-snippets 81 | 82 | # Local History for Visual Studio Code 83 | .history/ 84 | 85 | # Built Visual Studio Code Extensions 86 | *.vsix 87 | 88 | ### VisualStudioCode Patch ### 89 | # Ignore all local history of files 90 | .history 91 | .ionide 92 | 93 | # Support for Project snippet scope 94 | .vscode/*.code-snippets 95 | 96 | # Ignore code-workspaces 97 | *.code-workspace 98 | 99 | # End of https://www.toptal.com/developers/gitignore/api/macos,go,helm,visualstudiocode 100 | 101 | # pixi environments 102 | .pixi 103 | *.egg-info 104 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - revive 5 | - staticcheck 6 | settings: 7 | revive: 8 | rules: 9 | - name: unused-receiver 10 | severity: warning 11 | exclusions: 12 | generated: lax 13 | presets: 14 | - common-false-positives 15 | - legacy 16 | - std-error-handling 17 | paths: 18 | - third_party$ 19 | - builtin$ 20 | - examples$ 21 | formatters: 22 | enable: 23 | - goimports 24 | exclusions: 25 | generated: lax 26 | paths: 27 | - third_party$ 28 | - builtin$ 29 | - examples$ 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | # pre-commit-hooks 5 | - id: trailing-whitespace-fixer 6 | name: trailing-whitespace-fixer 7 | entry: pixi run trailing-whitespace-fixer 8 | language: system 9 | types: [text] 10 | - id: end-of-file-fixer 11 | name: end-of-file-fixer 12 | entry: pixi run end-of-file-fixer 13 | language: system 14 | types: [text] 15 | - id: check-added-large-files 16 | name: check-added-large-files 17 | entry: pixi run check-added-large-files 18 | language: system 19 | types: [text] 20 | # golangci-lint 21 | - id: golangci-lint 22 | name: golangci-lint 23 | entry: pixi run golangci-lint run ./... 24 | language: system 25 | pass_filenames: false 26 | types: [go] 27 | # helm-lint 28 | - id: helm-lint 29 | name: helm-lint 30 | entry: pixi run helm lint chart 31 | language: system 32 | pass_filenames: false 33 | types: [text] 34 | files: ^chart/Chart.yaml$ 35 | # helm-docs 36 | - id: helm-docs 37 | name: helm-docs 38 | entry: pixi run docs 39 | language: system 40 | pass_filenames: false 41 | types: [text] 42 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | [formatting] 2 | array_auto_collapse = true 3 | compact_arrays = true 4 | reorder_arrays = true 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder 2 | 3 | WORKDIR /workspace 4 | COPY go.* . 5 | RUN go mod download 6 | 7 | COPY cmd/ cmd/ 8 | COPY internal/ internal/ 9 | 10 | ARG TARGETOS TARGETARCH 11 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ 12 | go build -a -o manager cmd/main.go 13 | 14 | #-------------------------------------------------------------------------------------------------- 15 | 16 | FROM gcr.io/distroless/static:nonroot 17 | WORKDIR / 18 | COPY --from=builder /workspace/manager . 19 | USER 65532:65532 20 | 21 | ENTRYPOINT ["/manager"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Oliver Borchert 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switchboard 2 | 3 | ![License](https://img.shields.io/github/license/borchero/switchboard) 4 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/switchboard)](https://artifacthub.io/packages/search?repo=switchboard) 5 | [![CI - Application](https://github.com/borchero/switchboard/actions/workflows/ci-application.yml/badge.svg?branch=main)](https://github.com/borchero/switchboard/actions/workflows/ci-application.yml) 6 | [![CI - Chart](https://github.com/borchero/switchboard/actions/workflows/ci-chart.yml/badge.svg?branch=main)](https://github.com/borchero/switchboard/actions/workflows/ci-chart.yml) 7 | [![codecov](https://codecov.io/gh/borchero/switchboard/branch/main/graph/badge.svg?token=CI8KJLDRUP)](https://codecov.io/gh/borchero/switchboard) 8 | 9 | Switchboard is a Kubernetes operator that automates the creation of DNS records and TLS 10 | certificates when using [Traefik](https://github.com/traefik/traefik) v2 and its 11 | [`IngressRoute` custom resource](https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-ingressroute). 12 | 13 | Traefik is an amazing reverse proxy and load balancer for Kubernetes, but has two major issues when 14 | using it in production: 15 | 16 | - You cannot use Traefik to automatically issue TLS certificates from Let's Encrypt when running 17 | multiple Traefik instances (see 18 | [the documentation](https://doc.traefik.io/traefik/providers/kubernetes-crd/#letsencrypt-support-with-the-custom-resource-definition-provider)). 19 | - External tools do not support sourcing hostnames for DNS records from custom resources (including 20 | the Traefik `IngressRoute` CRD). 21 | 22 | Switchboard solves these two issues by integrating the Traefik `IngressRoute` CRD with external 23 | tools (_integrations_): 24 | 25 | - [cert-manager](https://cert-manager.io) can be used to create TLS certificates: Switchboard 26 | automatically creates a `Certificate` resource when an `IngressRoute` has the 27 | `.spec.tls.secretName` field set. The DNS alt names are taken either from `.spec.tls.domains` or 28 | (if unavailable) extracted automatically from all rules. The created certificate will then be 29 | used by Traefik to secure the connection. 30 | - [external-dns](https://github.com/kubernetes-sigs/external-dns) can be used to create DNS A 31 | records. First, DNS names are extracted from `.spec.tls.domains` and all rules as for the DNS alt 32 | names. Subsequently, a `DNSEndpoint` resource is created where all DNS names point to your 33 | Traefik instance. External-dns will pick up the `DNSEndpoint` and add appropriate DNS records in 34 | your configured provider. 35 | 36 | Switchboard allows you to freely choose which integrations you want to use and can, thus, be easily 37 | adopted incrementally. 38 | 39 | _Note: This version of Switchboard is a complete rewrite of Switchboard v0.1 which will not be 40 | maintained anymore. Please refer to the appropriate tags in this repository if you still need to 41 | use it. Be aware that this version of Switchboard provides significantly more functionality while 42 | being considerably more reliable due to its integration with external-dns._ 43 | 44 | ## Installation 45 | 46 | Switchboard can be conveniently installed using [Helm](https://helm.sh) version `>= 3.8.0`: 47 | 48 | ```bash 49 | helm install switchboard oci://ghcr.io/borchero/charts/switchboard 50 | ``` 51 | 52 | For a full installation guide, consult the 53 | [Switchboard Helm chart documentation](./chart/README.md). 54 | 55 | ## Usage 56 | 57 | As mentioned above, Switchboard processes Traefik `IngressRoute` resources. Let's assume, we have 58 | the following ingress route which forwards requests to an nginx backend: 59 | 60 | ```yaml 61 | apiVersion: traefik.io/v1alpha1 62 | kind: IngressRoute 63 | metadata: 64 | name: my-ingress 65 | spec: 66 | routes: 67 | - kind: Rule 68 | match: Host(`www.example.com`) && PathPrefix(`/images`) 69 | services: 70 | - name: nginx 71 | tls: 72 | secretName: www-tls-certificate 73 | ``` 74 | 75 | Switchboard now automatically extracts information from the ingress route object: 76 | 77 | - The ingress route is concerned with a single host, namely `www.example.com`. 78 | - Requests should be TLS-protected and a TLS certificate should be put into the 79 | `www-tls-certificate` secret. 80 | 81 | This information is now passed onto all _integrations_ that Switchboard is configured with. 82 | 83 | ### Integrations 84 | 85 | Integrations are entirely independent of each other. Enabling an integration causes Switchboard to 86 | generate an integration-specific resource (typically a CRD) for each ingress route that it 87 | processes. 88 | 89 | Consult the [Switchboard Helm chart documentation](./chart/README.md) for an overview of how to 90 | enable individual integrations. 91 | 92 | #### Cert-Manager 93 | 94 | The cert-manager integration allows Switchboard to create a `Certificate` resource for an 95 | `IngressRoute` if the ingress (1) specifies `.spec.tls.secretName` and (2) references at least one 96 | host. Using the example ingress route from above, Switchboard creates the following resource: 97 | 98 | ```yaml 99 | apiVersion: cert-manager.io/v1 100 | kind: Certificate 101 | metadata: 102 | # The name is automatically generated from the name of the ingress route. 103 | name: my-ingress-tls 104 | labels: 105 | kubernetes.io/managed-by: switchboard 106 | spec: 107 | # The issuer reference is obtained from the configuration of the cert-manager integration. 108 | issuerRef: 109 | kind: ClusterIssuer 110 | name: ca-issuer 111 | dnsNames: 112 | - www.example.com 113 | secretName: www-tls-certificate 114 | ``` 115 | 116 | #### External-DNS 117 | 118 | The external-dns integration causes Switchboard to create a `DNSEndpoint` resource for an 119 | `IngressRoute` if the ingress references at least one host. Given the example ingress route above, 120 | Switchboard creates the following endpoint: 121 | 122 | ```yaml 123 | apiVersion: externaldns.k8s.io/v1alpha1 124 | kind: DNSEndpoint 125 | metadata: 126 | # The name is the same as the ingress's name. 127 | name: my-ingress 128 | labels: 129 | kubernetes.io/managed-by: switchboard 130 | spec: 131 | endpoints: 132 | - dnsName: www.example.com 133 | recordTTL: 300 134 | recordType: A 135 | targets: 136 | # The target is the public (or, if unavailable, private) IP address of your Traefik 137 | # instance. The Kubernetes service to source the IP from is obtained from the configuration 138 | # of the external-dns integration. 139 | - 10.96.0.10 140 | ``` 141 | 142 | ### Customization 143 | 144 | #### Manually Set Hosts 145 | 146 | By default, Switchboard automatically extracts hosts from an ingress route by processing all rules 147 | and extracting hosts from `` Host(`...`) `` blocks. If you want to specify the set of hosts that 148 | are used for TLS certificates and DNS endpoints yourself, set `.spec.tls.domains`, e.g.: 149 | 150 | ```yaml 151 | spec: 152 | tls: 153 | domains: 154 | - main: example.com 155 | sans: 156 | - www.example.com 157 | ``` 158 | 159 | #### Disable Processing of an Ingress Route 160 | 161 | By default, Switchboard process all `IngressRoute` objects in your cluster. While you can constrain 162 | Switchboard to only process objects with the `kubernetes.io/ingress.class` annotation set to a 163 | specific value (see the 164 | [Switchboard Helm chart documentation](https://github.com/borchero/switchboard-chart)), you can 165 | also disable processing for individual ingress routes by setting an additional annotation: 166 | 167 | ```yaml 168 | metadata: 169 | annotations: 170 | switchboard.borchero.com/ignore: "all" 171 | ``` 172 | 173 | By setting the `ignore` annotation to `all` (or `true`), Switchboard does not process the ingress 174 | route at all. For more fine-grained control, the value of this annotation can also be set to a 175 | comma-separated list of integrations (possible values `cert-manager`, `external-dns`). 176 | 177 | ## License 178 | 179 | Switchboard is licensed under the [MIT License](./LICENSE). 180 | -------------------------------------------------------------------------------- /chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | type: application 3 | name: switchboard 4 | version: 0.0.0 5 | appVersion: 0.0.0 6 | home: https://github.com/borchero/switchboard 7 | sources: 8 | - https://github.com/borchero/switchboard 9 | keywords: 10 | - dns 11 | - tls 12 | - external-dns 13 | - cert-manager 14 | - traefik 15 | - dnsendpoint 16 | 17 | dependencies: 18 | - name: external-dns 19 | version: 8.7.11 20 | repository: oci://registry-1.docker.io/bitnamicharts 21 | condition: dependencies.external-dns.install 22 | - name: cert-manager 23 | version: 1.17.1 24 | repository: https://charts.jetstack.io 25 | condition: dependencies.cert-manager.install 26 | -------------------------------------------------------------------------------- /chart/README.md: -------------------------------------------------------------------------------- 1 | # Switchboard Helm Chart 2 | 3 | ![Type: application](https://img.shields.io/badge/Type-application-informational) 4 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/switchboard)](https://artifacthub.io/packages/search?repo=switchboard) 5 | ![License](https://img.shields.io/github/license/borchero/switchboard-chart) 6 | 7 | This directory contains the Helm chart as well as detailed instructions for deploying the 8 | Switchboard Kubernetes operator. Please read through this repository's root README to understand 9 | how Switchboard works. 10 | 11 | ## Installation 12 | 13 | ### Prerequisities 14 | 15 | Since Switchboard processes Traefik CRDs, you must make sure that your Kubernetes cluster has all 16 | [Traefik v2](https://github.com/traefik/traefik) CRDs installed. 17 | 18 | Depending on the integrations that you want to enable, you further need to have the following 19 | components installed: 20 | 21 | - **cert-manager integration**: Requires [cert-manager](https://cert-manager.io) and its CRDs to be 22 | installed. Can optionally be done by setting `cert-manager.install = true` in this chart. 23 | - **external-dns integration**: Requires 24 | [external-dns](https://github.com/kubernetes-sigs/external-dns) along with its `DNSEndpoint` CRD 25 | installed. Can optionally be done by setting `external-dns.install = true` in this chart. 26 | 27 | ### Install 28 | 29 | Switchboard can be installed with Helm version `>= 3.7.0`. For Helm version `< 3.8.0`, you need to 30 | set `HELM_EXPERIMENTAL_OCI=1`. Then you can install the chart directly with the following command: 31 | 32 | ```bash 33 | helm install switchboard oci://ghcr.io/borchero/charts/switchboard 34 | ``` 35 | 36 | By default, this installs Switchboard with no integrations enabled, i.e. it will not create any 37 | resources. Integrations can be enabled by setting `integrations..enabled` to `true`. Consult 38 | the configuration options to check which integrations you want to enable. 39 | 40 | _Note: You can check your Helm version via `helm version`._ 41 | 42 | ## Values 43 | 44 | The following lists all values that may be set when installing this chart (see 45 | [`values.yaml`](./values.yaml) for a more structured overview): 46 | 47 | | Key | Type | Default | Description | 48 | |-----|------|---------|-------------| 49 | | affinity | object | `{}` | | 50 | | cert-manager.crds.enabled | bool | `true` | | 51 | | certificateIssuer.create | bool | `false` | Whether an ACME certificate issuer should be created for use with cert-manager. | 52 | | certificateIssuer.email | string | `nil` | | 53 | | certificateIssuer.solvers | list | `[]` | The solvers to use for verifying that the domain is owned in the ACME challenge. See: https://cert-manager.io/docs/configuration/acme/ | 54 | | dependencies.cert-manager.install | bool | `false` | | 55 | | dependencies.external-dns.install | bool | `false` | | 56 | | image.name | string | `"ghcr.io/borchero/switchboard"` | The switchboard image to use. | 57 | | image.tag | string | `nil` | The switchboard image tag to use. If not provided, assumes the same version as the chart. | 58 | | integrations.certManager.certificateTemplate | object | `{}` | The certificate template to use when creating certificates via the cert-manager integration. Unless `certificateIssuer.create` is set to `true` when installing this chart, setting `.spec.IssuerRef` is required. | 59 | | integrations.certManager.enabled | bool | `false` | Whether the cert-manager integration should be enabled. If enabled, `Certificate` resources are created by Switchboard. Setting this to `true` requires specifying an issuer via `integrations.certManager.issuer` or letting the chart create its own issuer by setting `certificateIssuer.create = true` and specifying additional properties for the certificate issuer. | 60 | | integrations.externalDNS.enabled | bool | `false` | Whether the external-dns integration should be enabled. If enabled `DNSEndpoint` resources are created by Switchboard. Setting this to `true` requires specifying the target via `integrations.externalDNS.target`. | 61 | | integrations.externalDNS.targetIPs | list | `[]` | The static IP addresses that created DNS records should point to. Must not be provided if the target service is set. | 62 | | integrations.externalDNS.targetService.name | string | `nil` | The name of the (Traefik) service whose IP address should be used for DNS records. | 63 | | integrations.externalDNS.targetService.namespace | string | `nil` | The namespace of the (Traefik) service whose IP address should be used for DNS records. | 64 | | metrics.enabled | bool | `true` | Whether the metrics endpoint should be enabled. | 65 | | metrics.port | int | `9090` | The port on which Prometheus metrics can be scraped on path `/metrics`. | 66 | | nodeSelector | object | `{}` | | 67 | | podAnnotations | object | `{}` | Annotations to set on the switchboard pod. | 68 | | podMonitor.create | bool | `false` | Whether a PodMonitor should be created which can be used to scrape the metrics endpoint. Ignored if `metrics.enabled` is set to `false` | 69 | | podMonitor.namespace | string | `nil` | The namespace where the monitor should be created in. Defaults to the release namespace. | 70 | | replicas | int | `1` | The number of manager replicas to use. | 71 | | resources | object | `{}` | The resources to use for the operator. | 72 | | selector.ingressClass | string | `nil` | When set, Switchboard only processes ingress routes with the `kubernetes.io/ingress.class` annotation set to this value. | 73 | | tolerations | list | `[]` | | 74 | -------------------------------------------------------------------------------- /chart/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | # Switchboard Helm Chart 2 | 3 | ![Type: application](https://img.shields.io/badge/Type-application-informational) 4 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/switchboard)](https://artifacthub.io/packages/search?repo=switchboard) 5 | ![License](https://img.shields.io/github/license/borchero/switchboard-chart) 6 | 7 | This directory contains the Helm chart as well as detailed instructions for deploying the 8 | Switchboard Kubernetes operator. Please read through this repository's root README to understand 9 | how Switchboard works. 10 | 11 | ## Installation 12 | 13 | ### Prerequisities 14 | 15 | Since Switchboard processes Traefik CRDs, you must make sure that your Kubernetes cluster has all 16 | [Traefik v2](https://github.com/traefik/traefik) CRDs installed. 17 | 18 | Depending on the integrations that you want to enable, you further need to have the following 19 | components installed: 20 | 21 | - **cert-manager integration**: Requires [cert-manager](https://cert-manager.io) and its CRDs to be 22 | installed. Can optionally be done by setting `cert-manager.install = true` in this chart. 23 | - **external-dns integration**: Requires 24 | [external-dns](https://github.com/kubernetes-sigs/external-dns) along with its `DNSEndpoint` CRD 25 | installed. Can optionally be done by setting `external-dns.install = true` in this chart. 26 | 27 | ### Install 28 | 29 | Switchboard can be installed with Helm version `>= 3.7.0`. For Helm version `< 3.8.0`, you need to 30 | set `HELM_EXPERIMENTAL_OCI=1`. Then you can install the chart directly with the following command: 31 | 32 | ```bash 33 | helm install switchboard oci://ghcr.io/borchero/charts/switchboard 34 | ``` 35 | 36 | By default, this installs Switchboard with no integrations enabled, i.e. it will not create any 37 | resources. Integrations can be enabled by setting `integrations..enabled` to `true`. Consult 38 | the configuration options to check which integrations you want to enable. 39 | 40 | _Note: You can check your Helm version via `helm version`._ 41 | 42 | ## Values 43 | 44 | The following lists all values that may be set when installing this chart (see 45 | [`values.yaml`](./values.yaml) for a more structured overview): 46 | 47 | {{ template "chart.valuesTable" . }} 48 | -------------------------------------------------------------------------------- /chart/templates/_config.tpl: -------------------------------------------------------------------------------- 1 | {{ define "config" }} 2 | {{- $cfg := .Values.config -}} 3 | health: 4 | healthProbeBindAddress: :8081 5 | leaderElection: 6 | leaderElect: {{ gt .Values.replicas 1.0 }} 7 | resourceName: {{ .Release.Name }}.switchboard.borchero.com 8 | resourceNamespace: {{ .Release.Namespace }} 9 | 10 | {{ if .Values.metrics.enabled }} 11 | metrics: 12 | bindAddress: :{{ .Values.metrics.port }} 13 | {{ end }} 14 | 15 | {{ if .Values.selector.ingressClass }} 16 | selector: 17 | ingressClass: {{ .Values.selector.ingressClass }} 18 | {{ end }} 19 | 20 | {{- $certManager := .Values.integrations.certManager -}} 21 | {{- $externalDNS := .Values.integrations.externalDNS -}} 22 | {{ if or $certManager.enabled $externalDNS.enabled }} 23 | integrations: 24 | {{ if $certManager.enabled }} 25 | certManager: 26 | {{ if $certManager.certificateTemplate }} 27 | certificateTemplate: 28 | {{ toYaml $certManager.certificateTemplate | nindent 6 }} 29 | {{ else if .Values.certificateIssuer.create }} 30 | certificateTemplate: 31 | spec: 32 | issuerRef: 33 | kind: ClusterIssuer 34 | name: {{ .Release.Name }}-letsencrypt-issuer 35 | {{ else }} 36 | {{ fail "certificate template is not provided and no issuer is created by this chart" }} 37 | {{ end }} 38 | {{ end }} 39 | {{ if $externalDNS.enabled }} 40 | externalDNS: 41 | {{ if and $externalDNS.targetService.name $externalDNS.targetService.namespace }} 42 | targetService: 43 | name: {{ $externalDNS.targetService.name }} 44 | namespace: {{ $externalDNS.targetService.namespace }} 45 | {{ else if $externalDNS.targetIPs }} 46 | targetIPs: 47 | {{ toYaml $externalDNS.targetIPs | nindent 6 }} 48 | {{ else }} 49 | {{ fail "exactly one of target service and target IPs must be set for external dns" }} 50 | {{ end }} 51 | {{ end }} 52 | {{ end }} 53 | 54 | {{ end }} 55 | -------------------------------------------------------------------------------- /chart/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{ define "image.tag" }} 2 | {{- if .Values.image.tag -}} 3 | {{ .Values.image.tag }} 4 | {{- else -}} 5 | {{ .Chart.Version }} 6 | {{- end -}} 7 | {{ end }} 8 | -------------------------------------------------------------------------------- /chart/templates/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Release.Name }}-config 5 | data: 6 | config.yaml: | 7 | {{ include "config" . | nindent 4 | trim }} 8 | -------------------------------------------------------------------------------- /chart/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ .Release.Name }} 5 | labels: 6 | app.kubernetes.io/name: {{ .Chart.Name }} 7 | app.kubernetes.io/instance: {{ .Release.Name }} 8 | spec: 9 | replicas: {{ .Values.replicas }} 10 | strategy: 11 | type: RollingUpdate 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/name: {{ .Chart.Name }} 15 | app.kubernetes.io/instance: {{ .Release.Name }} 16 | template: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/name: {{ .Chart.Name }} 20 | app.kubernetes.io/instance: {{ .Release.Name }} 21 | annotations: 22 | {{ toYaml .Values.podAnnotations | nindent 8 }} 23 | spec: 24 | serviceAccountName: {{ .Release.Name }} 25 | {{- if .Values.image.pullSecrets }} 26 | imagePullSecrets: 27 | {{- range .Values.image.pullSecrets }} 28 | - name: {{ . }} 29 | {{- end }} 30 | {{- end }} 31 | containers: 32 | - name: switchboard 33 | image: {{ .Values.image.name }}:{{ include "image.tag" . }} 34 | volumeMounts: 35 | - name: config 36 | mountPath: /etc/switchboard/config.yaml 37 | subPath: config.yaml 38 | ports: 39 | - name: metrics 40 | containerPort: {{ .Values.metrics.port }} 41 | readinessProbe: 42 | httpGet: 43 | path: /readyz 44 | port: 8081 45 | initialDelaySeconds: 5 46 | livenessProbe: 47 | httpGet: 48 | path: /healthz 49 | port: 8081 50 | periodSeconds: 30 51 | resources: 52 | {{ toYaml .Values.resources | nindent 12 }} 53 | volumes: 54 | - name: config 55 | configMap: 56 | name: {{ .Release.Name }}-config 57 | {{- with .Values.nodeSelector }} 58 | nodeSelector: 59 | {{- toYaml . | nindent 8 }} 60 | {{- end }} 61 | {{- with .Values.affinity }} 62 | affinity: 63 | {{- toYaml . | nindent 8 }} 64 | {{- end }} 65 | {{- with .Values.tolerations }} 66 | tolerations: 67 | {{- toYaml . | nindent 8 }} 68 | {{- end }} 69 | -------------------------------------------------------------------------------- /chart/templates/issuer.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.certificateIssuer.create }} 2 | apiVersion: cert-manager.io/v1 3 | kind: ClusterIssuer 4 | metadata: 5 | name: {{ .Release.Name }}-letsencrypt-issuer 6 | spec: 7 | acme: 8 | email: {{ .Values.certificateIssuer.email | required "email for certificate issuer missing" }} 9 | server: https://acme-v02.api.letsencrypt.org/directory 10 | privateKeySecretRef: 11 | name: {{ .Release.Name }}-letsencrypt-issuer 12 | solvers: 13 | {{ toYaml .Values.certificateIssuer.solvers | nindent 6 }} 14 | {{ end }} 15 | -------------------------------------------------------------------------------- /chart/templates/monitor.yaml: -------------------------------------------------------------------------------- 1 | {{ if and .Values.metrics.enabled .Values.podMonitor.create }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PodMonitor 4 | metadata: 5 | name: {{ .Release.Name }} 6 | namespace: {{ .Values.podMonitor.namespace | default .Release.Namespace }} 7 | spec: 8 | namespaceSelector: 9 | matchNames: 10 | - {{ .Release.Namespace }} 11 | selector: 12 | matchLabels: 13 | app.kubernetes.io/name: {{ .Chart.Name }} 14 | app.kubernetes.io/instance: {{ .Release.Name }} 15 | podMetricsEndpoint: 16 | - port: metrics 17 | path: /metrics 18 | {{ end }} 19 | -------------------------------------------------------------------------------- /chart/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ .Release.Name }} 5 | 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1 8 | kind: ClusterRole 9 | metadata: 10 | name: {{ .Release.Name }} 11 | rules: 12 | # Controller Permissions 13 | - apiGroups: ["traefik.io"] 14 | resources: ["ingressroutes"] 15 | verbs: ["get", "list", "watch"] 16 | # Integrations 17 | {{ if .Values.integrations.certManager.enabled }} 18 | - apiGroups: ["cert-manager.io"] 19 | resources: ["certificates"] 20 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 21 | {{ end }} 22 | {{ if .Values.integrations.externalDNS.enabled }} 23 | - apiGroups: ["externaldns.k8s.io"] 24 | resources: ["dnsendpoints"] 25 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 26 | - apiGroups: [""] 27 | resources: ["services"] 28 | verbs: ["get", "list", "watch"] 29 | {{ end }} 30 | # Leader Election 31 | - apiGroups: [""] 32 | resources: ["configmaps"] 33 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 34 | - apiGroups: ["coordination.k8s.io"] 35 | resources: ["leases"] 36 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 37 | - apiGroups: [""] 38 | resources: ["events"] 39 | verbs: ["create", "patch"] 40 | 41 | --- 42 | apiVersion: rbac.authorization.k8s.io/v1 43 | kind: ClusterRoleBinding 44 | metadata: 45 | name: {{ .Release.Name }} 46 | roleRef: 47 | apiGroup: rbac.authorization.k8s.io 48 | kind: ClusterRole 49 | name: {{ .Release.Name }} 50 | subjects: 51 | - kind: ServiceAccount 52 | name: {{ .Release.Name }} 53 | namespace: {{ .Release.Namespace }} 54 | -------------------------------------------------------------------------------- /chart/values.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | # -- The switchboard image to use. 3 | name: ghcr.io/borchero/switchboard 4 | # -- The switchboard image tag to use. If not provided, assumes the same version as the chart. 5 | tag: ~ 6 | # -- Optionally specify an array of imagePullSecrets to use when pulling from a private 7 | # container registry. Secrets must be manually created in the namespace. 8 | # pullSecrets: 9 | # - "myExistingSecret" 10 | 11 | # -- The number of manager replicas to use. 12 | replicas: 1 13 | 14 | # -- Annotations to set on the switchboard pod. 15 | podAnnotations: {} 16 | 17 | # -- The resources to use for the operator. 18 | resources: 19 | {} 20 | # requests: 21 | # cpu: 5m 22 | # memory: 25Mi 23 | # limits: 24 | # cpu: 50m 25 | # memory: 40Mi 26 | 27 | nodeSelector: {} 28 | 29 | tolerations: [] 30 | 31 | affinity: {} 32 | 33 | selector: 34 | # -- When set, Switchboard only processes ingress routes with the `kubernetes.io/ingress.class` 35 | # annotation set to this value. 36 | ingressClass: ~ 37 | 38 | integrations: 39 | certManager: 40 | # -- Whether the cert-manager integration should be enabled. If enabled, `Certificate` 41 | # resources are created by Switchboard. Setting this to `true` requires specifying an issuer 42 | # via `integrations.certManager.issuer` or letting the chart create its own issuer by 43 | # setting `certificateIssuer.create = true` and specifying additional properties for the 44 | # certificate issuer. 45 | enabled: false 46 | # -- The certificate template to use when creating certificates via the cert-manager 47 | # integration. Unless `certificateIssuer.create` is set to `true` when installing this 48 | # chart, setting `.spec.IssuerRef` is required. 49 | certificateTemplate: {} 50 | externalDNS: 51 | # -- Whether the external-dns integration should be enabled. If enabled `DNSEndpoint` resources 52 | # are created by Switchboard. Setting this to `true` requires specifying the target via 53 | # `integrations.externalDNS.target`. 54 | enabled: false 55 | # -- The static IP addresses that created DNS records should point to. Must not be provided 56 | # if the target service is set. 57 | targetIPs: [] 58 | targetService: 59 | # -- The name of the (Traefik) service whose IP address should be used for DNS records. 60 | name: ~ 61 | # -- The namespace of the (Traefik) service whose IP address should be used for DNS records. 62 | namespace: ~ 63 | 64 | metrics: 65 | # -- Whether the metrics endpoint should be enabled. 66 | enabled: true 67 | # -- The port on which Prometheus metrics can be scraped on path `/metrics`. 68 | port: 9090 69 | 70 | #-------------------------------------------------------------------------------------------------- 71 | # THIRD-PARTY RESOURCES 72 | #-------------------------------------------------------------------------------------------------- 73 | 74 | podMonitor: 75 | # -- Whether a PodMonitor should be created which can be used to scrape the metrics endpoint. 76 | # Ignored if `metrics.enabled` is set to `false` 77 | create: false 78 | # -- The namespace where the monitor should be created in. Defaults to the release namespace. 79 | namespace: ~ 80 | 81 | certificateIssuer: 82 | # -- Whether an ACME certificate issuer should be created for use with cert-manager. 83 | create: false 84 | # -- This email should be set to something useful -- it is used to send emails when TLS 85 | # certificates are about to expire. 86 | email: ~ 87 | # -- The solvers to use for verifying that the domain is owned in the ACME challenge. 88 | # See: https://cert-manager.io/docs/configuration/acme/ 89 | solvers: [] 90 | 91 | #-------------------------------------------------------------------------------------------------- 92 | # EXTERNAL DEPENDENCIES 93 | #-------------------------------------------------------------------------------------------------- 94 | 95 | dependencies: 96 | external-dns: 97 | install: false 98 | cert-manager: 99 | install: false 100 | 101 | external-dns: 102 | crd: 103 | create: true 104 | sources: 105 | - crd 106 | - service 107 | - ingress 108 | 109 | #-------------------------------------------------------------------------------------------------- 110 | 111 | cert-manager: 112 | crds: 113 | enabled: true 114 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | 8 | configv1 "github.com/borchero/switchboard/internal/config/v1" 9 | "github.com/borchero/switchboard/internal/controllers" 10 | "github.com/borchero/zeus/pkg/zeus" 11 | certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 12 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 13 | "go.uber.org/zap" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/runtime" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 18 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 19 | _ "k8s.io/client-go/plugin/pkg/client/auth" 20 | ctrl "sigs.k8s.io/controller-runtime" 21 | "sigs.k8s.io/controller-runtime/pkg/healthz" 22 | "sigs.k8s.io/controller-runtime/pkg/metrics/server" 23 | "sigs.k8s.io/external-dns/endpoint" 24 | "sigs.k8s.io/yaml" 25 | ) 26 | 27 | func main() { 28 | var cfgFile string 29 | flag.StringVar(&cfgFile, "config", "/etc/switchboard/config.yaml", "The config file to use.") 30 | flag.Parse() 31 | 32 | // Initialize logger 33 | ctx := context.Background() 34 | logger := zeus.Logger(ctx) 35 | defer zeus.Sync() 36 | 37 | // Load the config file if available 38 | var config configv1.Config 39 | if cfgFile != "" { 40 | contents, err := os.ReadFile(cfgFile) 41 | if err != nil { 42 | logger.Fatal("failed to read config file", zap.Error(err)) 43 | } 44 | if err := yaml.Unmarshal(contents, &config); err != nil { 45 | logger.Fatal("failed to parse config file", zap.Error(err)) 46 | } 47 | } 48 | 49 | // Initialize the options and the schema 50 | options := ctrl.Options{ 51 | Scheme: runtime.NewScheme(), 52 | LeaderElection: config.LeaderElection.LeaderElect, 53 | LeaderElectionID: config.LeaderElection.ResourceName, 54 | LeaderElectionNamespace: config.LeaderElection.ResourceNamespace, 55 | Metrics: server.Options{ 56 | BindAddress: config.Metrics.BindAddress, 57 | }, 58 | HealthProbeBindAddress: config.Health.HealthProbeBindAddress, 59 | } 60 | initScheme(config, options.Scheme) 61 | 62 | // Create the manager 63 | manager, err := ctrl.NewManager(ctrl.GetConfigOrDie(), options) 64 | if err != nil { 65 | logger.Fatal("unable to create manager", zap.Error(err)) 66 | } 67 | 68 | // Create the controllers 69 | controller, err := controllers.NewIngressRouteReconciler(manager.GetClient(), logger, config) 70 | if err != nil { 71 | logger.Fatal("unable to initialize ingress route controller", zap.Error(err)) 72 | } 73 | if err := controller.SetupWithManager(manager); err != nil { 74 | logger.Fatal("unable to start ingress route controller", zap.Error(err)) 75 | } 76 | 77 | // Add health check endpoints 78 | if err := manager.AddReadyzCheck("readyz", healthz.Ping); err != nil { 79 | logger.Fatal("unable to set up ready check at /readyz", zap.Error(err)) 80 | } 81 | if err := manager.AddHealthzCheck("healthz", healthz.Ping); err != nil { 82 | logger.Fatal("unable to set up health check at /healthz", zap.Error(err)) 83 | } 84 | 85 | // Start the manager 86 | logger.Info("launching manager") 87 | if err := manager.Start(ctrl.SetupSignalHandler()); err != nil { 88 | logger.Fatal("failed to run manager", zap.Error(err)) 89 | } 90 | logger.Info("gracefully shut down") 91 | } 92 | 93 | func initScheme(config configv1.Config, scheme *runtime.Scheme) { 94 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 95 | utilruntime.Must(traefik.AddToScheme(scheme)) 96 | 97 | if config.Integrations.CertManager != nil { 98 | utilruntime.Must(certmanager.AddToScheme(scheme)) 99 | } 100 | 101 | if config.Integrations.ExternalDNS != nil { 102 | groupVersion := schema.GroupVersion{Group: "externaldns.k8s.io", Version: "v1alpha1"} 103 | scheme.AddKnownTypes(groupVersion, 104 | &endpoint.DNSEndpoint{}, 105 | &endpoint.DNSEndpointList{}, 106 | ) 107 | metav1.AddToGroupVersion(scheme, groupVersion) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: main 3 | comment: 4 | layout: header,reach,diff,flags,files,footer 5 | require_changes: false 6 | flag_management: 7 | default_rules: 8 | carryforward: true 9 | -------------------------------------------------------------------------------- /dev/config.yaml: -------------------------------------------------------------------------------- 1 | health: 2 | healthProbeBindAddress: :8081 3 | leaderElection: 4 | leaderElect: false 5 | resourceName: switchboard.borchero.com 6 | resourceNamespace: default 7 | integrations: 8 | externalDns: 9 | target: 10 | name: kube-dns 11 | namespace: kube-system 12 | certManager: 13 | issuer: 14 | kind: ClusterIssuer 15 | name: ca-issuer 16 | -------------------------------------------------------------------------------- /dev/manifests/ca-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: ca-key-pair 5 | data: 6 | tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMrVENDQWVHZ0F3SUJBZ0lKQUtQR3dLRGwvNUhuTUEwR0NTcUdTSWIzRFFFQkN3VUFNQk14RVRBUEJnTlYKQkFNTUNHcHZjMmgyWVc1c01CNFhEVEU1TURneU1qRTJNRFUxT0ZvWERUSTVNRGd4T1RFMk1EVTFPRm93RXpFUgpNQThHQTFVRUF3d0lhbTl6YUhaaGJtd3dnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCCkFRQ3doU0IvcVc2L2tMYjJ6cHUrRUp2RDl3SEZhcStRQS8wSkgvTGxseW83ekFGeCtISHErQ09BYmsrQzhCNHQKL0hVRXNuczVSTDA5Q1orWDRqNnBiSkZkS2R1UHhYdTVaVllua3hZcFVEVTd5ZzdPU0tTWnpUbklaNzIzc01zMApSNmpZbi9Ecmo0eFhNSkVmSFVEcVllU1dsWnIzcWkxRUZhMGM3ZlZEeEgrNHh0WnROTkZPakg3YzZEL3ZXa0lnCldRVXhpd3Vzc2U2S01PV2pEbnYvNFZyamVsMlFnVVlVYkhDeWVaSG1jdGkrSzBMV0Nmby9SZzZQdWx3cmJEa2gKam1PZ1l0MzBwZGhYME9aa0F1a2xmVURIZnA4YmpiQ29JMnRhWUFCQTZBS2pLc08zNUxBRVU3OUNMMW1MVkh1WgpBQ0k1VWppamEzVlBXVkhTd21KUEp5dXhBZ01CQUFHalVEQk9NQjBHQTFVZERnUVdCQlFtbDVkVEFaaXhGS2hqCjkzd3VjUldoYW8vdFFqQWZCZ05WSFNNRUdEQVdnQlFtbDVkVEFaaXhGS2hqOTN3dWNSV2hhby90UWpBTUJnTlYKSFJNRUJUQURBUUgvTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCK2tsa1JOSlVLQkxYOHlZa3l1VTJSSGNCdgpHaG1tRGpKSXNPSkhac29ZWGRMbEcxcFpORmpqUGFPTDh2aDQ0Vmw5OFJoRVpCSHNMVDFLTWJwMXN1NkNxajByClVHMWtwUkJlZitJT01UNE1VN3ZSSUNpN1VPbFJMcDFXcDBGOGxhM2hQT2NSYjJ5T2ZGcVhYeVpXWGY0dDBCNDUKdEhpK1pDTkhCOUZ4alNSeWNiR1lWaytUS3B2aEphU1lOTUdKM2R4REthUDcrRHgzWGNLNnNBbklBa2h5SThhagpOVSttdzgvdG1Sa1A0SW4va1hBUitSaTBxVW1Iai92d3ZuazRLbTdaVXkxRllIOERNZVM1TmtzbisvdUhsUnhSClY3RG5uMDM5VFJtZ0tiQXFONzJnS05MbzVjWit5L1lxREFZSFlybjk4U1FUOUpEZ3RJL0svQVRwVzhkWAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== 7 | tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBc0lVZ2Y2bHV2NUMyOXM2YnZoQ2J3L2NCeFdxdmtBUDlDUi95NVpjcU84d0JjZmh4CjZ2Z2pnRzVQZ3ZBZUxmeDFCTEo3T1VTOVBRbWZsK0krcVd5UlhTbmJqOFY3dVdWV0o1TVdLVkExTzhvT3praWsKbWMwNXlHZTl0N0RMTkVlbzJKL3c2NCtNVnpDUkh4MUE2bUhrbHBXYTk2b3RSQld0SE8zMVE4Ui91TWJXYlRUUgpUb3grM09nLzcxcENJRmtGTVlzTHJMSHVpakRsb3c1Ny8rRmE0M3Bka0lGR0ZHeHdzbm1SNW5MWXZpdEMxZ242ClAwWU9qN3BjSzJ3NUlZNWpvR0xkOUtYWVY5RG1aQUxwSlgxQXgzNmZHNDJ3cUNOcldtQUFRT2dDb3lyRHQrU3cKQkZPL1FpOVppMVI3bVFBaU9WSTRvMnQxVDFsUjBzSmlUeWNyc1FJREFRQUJBb0lCQUNFTkhET3JGdGg1a1RpUApJT3dxa2UvVVhSbUl5MHlNNHFFRndXWXBzcmUxa0FPMkFDWjl4YS96ZDZITnNlanNYMEM4NW9PbmtrTk9mUHBrClcxVS94Y3dLM1ZpRElwSnBIZ09VNzg1V2ZWRXZtU3dZdi9Fb1V3eHFHRVMvcnB5Z1drWU5WSC9XeGZGQlg3clMKc0dmeVltbXJvM09DQXEyLzNVVVFiUjcrT09md3kzSHdUdTBRdW5FSnBFbWU2RXdzdWIwZzhTTGp2cEpjSHZTbQpPQlNKSXJyL1RjcFRITjVPc1h1Vm5FTlVqV3BBUmRQT1NrRFZHbWtCbnkyaVZURElST3NGbmV1RUZ1NitXOWpqCmhlb1hNN2czbkE0NmlLenUzR0YwRWhLOFkzWjRmeE42NERkbWNBWnphaU1vMFJVaktWTFVqbVlQSEUxWWZVK3AKMkNYb3dNRUNnWUVBMTgyaU52UEkwVVlWaUh5blhKclNzd1YrcTlTRStvVi90U2ZSUUNGU2xsV0d3KzYyblRiVwpvNXpoL1RDQW9VTVNSbUFPZ0xKWU1LZUZ1SWdvTEoxN1pvWjN0U1czTlVtMmRpT0lPSHorcTQxQzM5MDRrUzM5CjkrYkFtVmtaSFA5VktLOEMraS9tek5mSkdHZEJadGIweWtTM2t3OUIxTHdnT3o3MDhFeXFSQ2tDZ1lFQTBXWlAKbzF2MThnV2tMK2FnUDFvOE13eDRPZlpTN3dKY3E0Z0xnUWhjYS9pSkttY0x0RFN4cUJHckJ4UVo0WTIyazlzdQpzTFVrNEJobGlVM29iUUJNaUdtMGtITHVBSEFRNmJvdWZBMUJwZjN2VFdHSkhSRjRMeFJsNzc2akw4UXI4VnpxClpURVBtY0R0T0hpYjdwb2I1Z2IzSDhiVGhYeUhmdGZxRW55alhFa0NnWUVBdk9DdDZZclZhTlQrWThjMmRFYk4Kd3dJOExBaUZtdjdkRjZFUjlCODJPWDRCeGR0WTJhRDFtNTNqN2NaVnpzNzFYOE1TN25FcDN1dkFqaElkbDI3KwpZbTJ1dUUyYVhIbDN5VTZ3RzBETFpUcnVIU0Z5TVI4ZithbHRTTXBDd0s1NXluSGpHVFp6dXpYaVBBbWpwRzdmCk1XbVRncE1IK3puc3UrNE9VNFBHUW9FQ2dZQWNqdUdKbS84YzlOd0JsR2lDZTJIK2JGTHhSTURteStHcm16QkcKZHNkMENqOWF3eGI3aXJ3MytjRGpoRUJMWExKcjA5YTRUdHdxbStrdElxenlRTG92V0l0QnNBcjVrRThlTVVBcAp0djBmRUZUVXJ0cXVWaldYNWlaSTNpMFBWS2ZSa1NSK2pJUmVLY3V3aWZKcVJpWkw1dU5KT0NxYzUvRHF3Yk93CnRjTHAwUUtCZ0VwdEw1SU10Sk5EQnBXbllmN0F5QVBhc0RWRE9aTEhNUGRpL2dvNitjSmdpUmtMYWt3eUpjV3IKU25QSG1TbFE0aEluNGMrNW1lbHBDWFdJaklLRCtjcTlxT2xmQmRtaWtYb2RVQ2pqWUJjNnVGQ1QrNWRkMWM4RwpiUkJQOUNtWk9GL0hOcHN0MEgxenhNd1crUHk5Q2VnR3hhZ0ZCekxzVW84N0xWR2h0VFFZCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg== 8 | -------------------------------------------------------------------------------- /dev/manifests/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: traefik.io/v1alpha1 2 | kind: IngressRoute 3 | metadata: 4 | name: my-ingress 5 | spec: 6 | routes: 7 | - kind: Rule 8 | match: Host(`www.example.com`) 9 | services: 10 | - name: nginx 11 | tls: 12 | secretName: www-tls-certificate 13 | -------------------------------------------------------------------------------- /dev/manifests/tls-issuer.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1 2 | kind: ClusterIssuer 3 | metadata: 4 | name: ca-issuer 5 | spec: 6 | ca: 7 | secretName: ca-key-pair 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/borchero/switchboard 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | dario.cat/mergo v1.0.1 7 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 8 | github.com/borchero/zeus v1.0.0 9 | github.com/cert-manager/cert-manager v1.17.2 10 | github.com/google/uuid v1.6.0 11 | github.com/stretchr/testify v1.10.0 12 | github.com/traefik/traefik/v3 v3.3.6 13 | go.uber.org/zap v1.27.0 14 | k8s.io/api v0.33.0 15 | k8s.io/apimachinery v0.33.0 16 | k8s.io/client-go v0.33.0 17 | sigs.k8s.io/controller-runtime v0.20.4 18 | sigs.k8s.io/external-dns v0.16.1 19 | sigs.k8s.io/yaml v1.4.0 20 | ) 21 | 22 | require ( 23 | github.com/aws/smithy-go v1.22.2 // indirect 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 | github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd // indirect 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 29 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 30 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 31 | github.com/fsnotify/fsnotify v1.9.0 // indirect 32 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 33 | github.com/go-acme/lego/v4 v4.23.1 // indirect 34 | github.com/go-jose/go-jose/v4 v4.1.0 // indirect 35 | github.com/go-kit/log v0.2.1 // indirect 36 | github.com/go-logfmt/logfmt v0.5.1 // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 40 | github.com/go-openapi/jsonreference v0.21.0 // indirect 41 | github.com/go-openapi/swag v0.23.1 // indirect 42 | github.com/gogo/protobuf v1.3.2 // indirect 43 | github.com/google/btree v1.1.3 // indirect 44 | github.com/google/gnostic-models v0.6.9 // indirect 45 | github.com/google/go-cmp v0.7.0 // indirect 46 | github.com/google/go-github/v28 v28.1.1 // indirect 47 | github.com/google/go-querystring v1.1.0 // indirect 48 | github.com/gorilla/context v1.1.2 // indirect 49 | github.com/gorilla/mux v1.8.1 // indirect 50 | github.com/gravitational/trace v1.5.1 // indirect 51 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect 52 | github.com/hashicorp/go-version v1.7.0 // indirect 53 | github.com/http-wasm/http-wasm-host-go v0.7.0 // indirect 54 | github.com/josharian/intern v1.0.0 // indirect 55 | github.com/json-iterator/go v1.1.12 // indirect 56 | github.com/mailru/easyjson v0.9.0 // indirect 57 | github.com/mattn/go-colorable v0.1.13 // indirect 58 | github.com/mattn/go-isatty v0.0.20 // indirect 59 | github.com/miekg/dns v1.1.65 // indirect 60 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 61 | github.com/modern-go/reflect2 v1.0.2 // indirect 62 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 63 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 64 | github.com/pkg/errors v0.9.1 // indirect 65 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 66 | github.com/prometheus/client_golang v1.22.0 // indirect 67 | github.com/prometheus/client_model v0.6.2 // indirect 68 | github.com/prometheus/common v0.63.0 // indirect 69 | github.com/prometheus/procfs v0.16.1 // indirect 70 | github.com/rs/zerolog v1.33.0 // indirect 71 | github.com/sirupsen/logrus v1.9.3 // indirect 72 | github.com/spf13/pflag v1.0.6 // indirect 73 | github.com/traefik/paerser v0.2.2 // indirect 74 | github.com/unrolled/render v1.0.2 // indirect 75 | github.com/vulcand/predicate v1.3.0 // indirect 76 | github.com/x448/float16 v0.8.4 // indirect 77 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 78 | go.opentelemetry.io/otel v1.34.0 // indirect 79 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect 80 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect 81 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 82 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect 83 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 // indirect 84 | go.opentelemetry.io/otel/log v0.8.0 // indirect 85 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 86 | go.opentelemetry.io/otel/sdk v1.34.0 // indirect 87 | go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect 88 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 89 | go.opentelemetry.io/proto/otlp v1.4.0 // indirect 90 | go.uber.org/multierr v1.11.0 // indirect 91 | golang.org/x/crypto v0.37.0 // indirect 92 | golang.org/x/mod v0.24.0 // indirect 93 | golang.org/x/net v0.39.0 // indirect 94 | golang.org/x/oauth2 v0.29.0 // indirect 95 | golang.org/x/sync v0.13.0 // indirect 96 | golang.org/x/sys v0.32.0 // indirect 97 | golang.org/x/term v0.31.0 // indirect 98 | golang.org/x/text v0.24.0 // indirect 99 | golang.org/x/time v0.11.0 // indirect 100 | golang.org/x/tools v0.32.0 // indirect 101 | gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect 102 | google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect 103 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect 104 | google.golang.org/grpc v1.71.0 // indirect 105 | google.golang.org/protobuf v1.36.6 // indirect 106 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 107 | gopkg.in/inf.v0 v0.9.1 // indirect 108 | gopkg.in/yaml.v3 v3.0.1 // indirect 109 | k8s.io/apiextensions-apiserver v0.32.3 // indirect 110 | k8s.io/klog/v2 v2.130.1 // indirect 111 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 112 | k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect 113 | sigs.k8s.io/gateway-api v1.2.1 // indirect 114 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 115 | sigs.k8s.io/randfill v1.0.0 // indirect 116 | sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect 117 | ) 118 | 119 | replace ( 120 | github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e 121 | github.com/go-check/check => github.com/containous/check v0.0.0-20170915194414-ca0bf163426a 122 | github.com/gorilla/mux => github.com/containous/mux v0.0.0-20220113180107-8ffa4f6d063c 123 | github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 124 | github.com/mailgun/multibuf => github.com/containous/multibuf v0.0.0-20190809014333-8b6c9a7e6bba 125 | ) 126 | 127 | // https://github.com/docker/compose/blob/e44222664abd07ce1d1fe6796d84d93cbc7468c3/go.mod#L131 128 | replace github.com/jaguilar/vt100 => github.com/tonistiigi/vt100 v0.0.0-20190402012908-ad4c4a574305 129 | 130 | // ambiguous import: found package github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/http in multiple modules 131 | // tencentcloud uses monorepo with multimodule but the go.mod files are incomplete. 132 | exclude github.com/tencentcloud/tencentcloud-sdk-go v3.0.83+incompatible 133 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 4 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 5 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= 6 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 7 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 8 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 9 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 10 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 11 | github.com/borchero/zeus v1.0.0 h1:fSqeTzQkoJJHL0vhHNxDjI2fZYmgnvr/wVw6Oh/lCFs= 12 | github.com/borchero/zeus v1.0.0/go.mod h1:U/sgPma1t5cX8JAZNuJH+rVDWZLQUsdjMhn1QivwIUk= 13 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 14 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 15 | github.com/cert-manager/cert-manager v1.17.2 h1:QQYTEOsHf/Z3BFzKH2sIILHJwZA5Ut0LYZlHyNViupg= 16 | github.com/cert-manager/cert-manager v1.17.2/go.mod h1:2TmjsTQF8GZqc8fgLhXWCfbA6YwWCUHKxerJNbFh9eU= 17 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 18 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 19 | github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd h1:0n+lFLh5zU0l6KSk3KpnDwfbPGAR44aRLgTbCnhRBHU= 20 | github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd/go.mod h1:BbQgeDS5i0tNvypwEoF1oNjOJw8knRAE1DnVvjDstcQ= 21 | github.com/containous/mux v0.0.0-20220113180107-8ffa4f6d063c h1:g6JvgTtfpS6AfhRjY87NZ0g39CrNDbdm8R+1CD85Cfo= 22 | github.com/containous/mux v0.0.0-20220113180107-8ffa4f6d063c/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg= 23 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= 29 | github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= 30 | github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 31 | github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 32 | github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= 33 | github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 34 | github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= 35 | github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 36 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 37 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 38 | github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= 39 | github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 40 | github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4= 41 | github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI= 42 | github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= 43 | github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= 44 | github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU= 45 | github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg= 46 | github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= 47 | github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= 48 | github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= 49 | github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 50 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 51 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 52 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 53 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 54 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 55 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 56 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 57 | github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= 58 | github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= 59 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 60 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 61 | github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= 62 | github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= 63 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 64 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 65 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 66 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 67 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 68 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 69 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 70 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 71 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 72 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 73 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 74 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 75 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 76 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 77 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 78 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 79 | github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo= 80 | github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= 81 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 82 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 83 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 84 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 85 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 86 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 87 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 88 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 89 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 90 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 91 | github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 92 | github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 93 | github.com/gravitational/trace v1.5.1 h1:CdSymAjkE1VOef+lsC5x29jX9WbgI0fBtnRqeT4Fh+c= 94 | github.com/gravitational/trace v1.5.1/go.mod h1:sJKfJHIQ7IkG8kvYpFPEr6mj3WDEdZ0YAc7xAD8w7lw= 95 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= 96 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= 97 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 98 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 99 | github.com/http-wasm/http-wasm-host-go v0.7.0 h1:+1KrRyOO6tWiDB24QrtSYyDmzFLBBs3jioKaUT0mq1c= 100 | github.com/http-wasm/http-wasm-host-go v0.7.0/go.mod h1:adXKcLmL7yuavH/e0kBAp7b3TgAHTo/enCduyN5bXGM= 101 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 102 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 103 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 104 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 105 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 106 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 107 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 108 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 109 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 110 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 111 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 112 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 113 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 114 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 115 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 116 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 117 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 118 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 119 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 120 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 121 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 122 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 123 | github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= 124 | github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= 125 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 126 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 127 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 128 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 129 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 130 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 131 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 132 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 133 | github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= 134 | github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 135 | github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= 136 | github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 137 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 138 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 139 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 140 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 141 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 142 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 143 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 144 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 145 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 146 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 147 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 148 | github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 149 | github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 150 | github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 151 | github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 152 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 153 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 154 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 155 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 156 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 157 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 158 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 159 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 160 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 161 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 162 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 163 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 164 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 165 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 166 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 167 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 168 | github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= 169 | github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24= 170 | github.com/traefik/traefik/v3 v3.3.6 h1:x+pZDsg3IlRX2NVFmLJrtCOeIsZRVgB8N19n7pLZrmY= 171 | github.com/traefik/traefik/v3 v3.3.6/go.mod h1:Xb812TIm7Gt5XTAQpWI8+aODfQGrG8np1rPoSXwM+TM= 172 | github.com/unrolled/render v1.0.2 h1:dGS3EmChQP3yOi1YeFNO/Dx+MbWZhdvhQJTXochM5bs= 173 | github.com/unrolled/render v1.0.2/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM= 174 | github.com/vulcand/predicate v1.3.0 h1:jtNe4PHbLJ649dR7Gl+MSAzUhLGtLspAkWlSjoOiXg8= 175 | github.com/vulcand/predicate v1.3.0/go.mod h1:opzv9MetRuMNnuoPeTSWtwzjcXsxQC00/fuWzkPTn4s= 176 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 177 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 178 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 179 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 180 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 181 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 182 | go.opentelemetry.io/collector/pdata v1.10.0 h1:oLyPLGvPTQrcRT64ZVruwvmH/u3SHTfNo01pteS4WOE= 183 | go.opentelemetry.io/collector/pdata v1.10.0/go.mod h1:IHxHsp+Jq/xfjORQMDJjSH6jvedOSTOyu3nbxqhWSYE= 184 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 185 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 186 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= 187 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= 188 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= 189 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= 190 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 191 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 192 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= 193 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= 194 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU= 195 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= 196 | go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= 197 | go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= 198 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 199 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 200 | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= 201 | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= 202 | go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= 203 | go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= 204 | go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= 205 | go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= 206 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 207 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 208 | go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= 209 | go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= 210 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 211 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 212 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 213 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 214 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 215 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 216 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 217 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 218 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 219 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 220 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 221 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 222 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 223 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 224 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 225 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 226 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 227 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 228 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 229 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 230 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 231 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 232 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 233 | golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= 234 | golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 235 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 236 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 237 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 238 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 239 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 240 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 241 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 242 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 245 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 246 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 247 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 248 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 249 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 250 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 251 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 252 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 253 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 254 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 255 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 256 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 257 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 258 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 259 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 260 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 261 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 262 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 263 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 264 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 265 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 266 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 267 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 268 | gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= 269 | gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 270 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 271 | google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= 272 | google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= 273 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= 274 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= 275 | google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= 276 | google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 277 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 278 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 279 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 280 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 281 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 282 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 283 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 284 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 285 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 286 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 287 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 288 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 289 | k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU= 290 | k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM= 291 | k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= 292 | k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= 293 | k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ= 294 | k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 295 | k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98= 296 | k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg= 297 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 298 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 299 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 300 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 301 | k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= 302 | k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 303 | sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= 304 | sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= 305 | sigs.k8s.io/external-dns v0.16.1 h1:0bml79osvrPd17FiBNXFFansCUEOUaQd1QBZpeXn2WM= 306 | sigs.k8s.io/external-dns v0.16.1/go.mod h1:53qBPX0sk6GUKlkJXLmfHEVnGv0oYjRNstYfPPrPBCw= 307 | sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= 308 | sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= 309 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= 310 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 311 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 312 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 313 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 314 | sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= 315 | sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 316 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 317 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 318 | -------------------------------------------------------------------------------- /internal/config/v1/config.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | v1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 5 | ) 6 | 7 | // Config is the Schema for the configs API 8 | type Config struct { 9 | ControllerConfig `json:",inline"` 10 | Selector IngressSelector `json:"selector"` 11 | Integrations IntegrationConfigs `json:"integrations"` 12 | } 13 | 14 | //------------------------------------------------------------------------------------------------- 15 | 16 | // ControllerConfig provides configuration for the controller. 17 | type ControllerConfig struct { 18 | Health HealthConfig `json:"health,omitempty"` 19 | LeaderElection LeaderElectionConfig `json:"leaderElection,omitempty"` 20 | Metrics MetricsConfig `json:"metrics,omitempty"` 21 | } 22 | 23 | // HealthConfig provides configuration for the controller health checks. 24 | type HealthConfig struct { 25 | HealthProbeBindAddress string `json:"healthProbeBindAddress,omitempty"` 26 | } 27 | 28 | // LeaderElectionConfig provides configuration for the leader election. 29 | type LeaderElectionConfig struct { 30 | LeaderElect bool `json:"leaderElect,omitempty"` 31 | ResourceName string `json:"resourceName,omitempty"` 32 | ResourceNamespace string `json:"resourceNamespace,omitempty"` 33 | } 34 | 35 | // MetricsConfig provides configuration for the controller metrics. 36 | type MetricsConfig struct { 37 | BindAddress string `json:"bindAddress,omitempty"` 38 | } 39 | 40 | //------------------------------------------------------------------------------------------------- 41 | 42 | // IngressSelector can be used to limit operations to ingresses with a specific class. 43 | type IngressSelector struct { 44 | IngressClass *string `json:"ingressClass,omitempty"` 45 | } 46 | 47 | // IntegrationConfigs describes the configurations for all integrations. 48 | type IntegrationConfigs struct { 49 | ExternalDNS *ExternalDNSIntegrationConfig `json:"externalDNS"` 50 | CertManager *CertManagerIntegrationConfig `json:"certManager"` 51 | } 52 | 53 | // ExternalDNSIntegrationConfig describes the configuration for the external-dns integration. 54 | // Exactly one of target and target IPs should be set. 55 | type ExternalDNSIntegrationConfig struct { 56 | TargetService *ServiceRef `json:"targetService,omitempty"` 57 | TargetIPs []string `json:"targetIPs,omitempty"` 58 | } 59 | 60 | // CertManagerIntegrationConfig describes the configuration for the cert-manager integration. 61 | type CertManagerIntegrationConfig struct { 62 | Template v1.Certificate `json:"certificateTemplate"` 63 | } 64 | 65 | // ServiceRef uniquely describes a Kubernetes service. 66 | type ServiceRef struct { 67 | Name string `json:"name"` 68 | Namespace string `json:"namespace"` 69 | } 70 | -------------------------------------------------------------------------------- /internal/controllers/ingressroute.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | configv1 "github.com/borchero/switchboard/internal/config/v1" 8 | "github.com/borchero/switchboard/internal/ext" 9 | "github.com/borchero/switchboard/internal/integrations" 10 | "github.com/borchero/switchboard/internal/switchboard" 11 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 12 | "go.uber.org/zap" 13 | apierrs "k8s.io/apimachinery/pkg/api/errors" 14 | ctrl "sigs.k8s.io/controller-runtime" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | // IngressRouteReconciler reconciles an IngressRoute object. 19 | type IngressRouteReconciler struct { 20 | client.Client 21 | logger *zap.Logger 22 | selector switchboard.Selector 23 | integrations []integrations.Integration 24 | } 25 | 26 | // NewIngressRouteReconciler creates a new IngressRouteReconciler. 27 | func NewIngressRouteReconciler( 28 | client client.Client, logger *zap.Logger, config configv1.Config, 29 | ) (IngressRouteReconciler, error) { 30 | integrations, err := integrationsFromConfig(config, client) 31 | if err != nil { 32 | return IngressRouteReconciler{}, fmt.Errorf("failed to initialize integrations: %s", err) 33 | } 34 | return IngressRouteReconciler{ 35 | Client: client, 36 | logger: logger, 37 | selector: switchboard.NewSelector(config.Selector.IngressClass), 38 | integrations: integrations, 39 | }, nil 40 | } 41 | 42 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 43 | // move the current state of the cluster closer to the desired state. 44 | func (r *IngressRouteReconciler) Reconcile( 45 | ctx context.Context, req ctrl.Request, 46 | ) (ctrl.Result, error) { 47 | logger := r.logger.With(zap.String("name", req.String())) 48 | 49 | // First, we retrieve the full resource 50 | var ingressRoute traefik.IngressRoute 51 | 52 | if err := r.Get(ctx, req.NamespacedName, &ingressRoute); err != nil { 53 | if !apierrs.IsNotFound(err) { 54 | logger.Error("unable to query for ingress route", zap.Error(err)) 55 | } 56 | return ctrl.Result{}, client.IgnoreNotFound(err) 57 | } 58 | 59 | // Then, we check if the resource should be processed 60 | if !r.selector.Matches(ingressRoute.Annotations) { 61 | logger.Debug("ignoring ingress route") 62 | return ctrl.Result{}, nil 63 | } 64 | logger.Debug("reconciling ingress route") 65 | 66 | // Now, we have to ensure that all the dependent resources exist by calling all integrations. 67 | // For this, we first have to extract information about the ingress. 68 | collection, err := switchboard.NewHostCollection(). 69 | WithTLSHostsIfAvailable(ingressRoute.Spec.TLS). 70 | WithRouteHostsIfRequired(ingressRoute.Spec.Routes) 71 | if err != nil { 72 | logger.Error("failed to parse hosts from ingress route", zap.Error(err)) 73 | return ctrl.Result{}, err 74 | } 75 | info := integrations.IngressInfo{ 76 | Hosts: collection.Hosts(), 77 | TLSSecretName: ext.AndThen(ingressRoute.Spec.TLS, func(tls traefik.TLS) string { 78 | return tls.SecretName 79 | }), 80 | } 81 | 82 | // Then, we can run the integrations 83 | for _, itg := range r.integrations { 84 | if !r.selector.MatchesIntegration(ingressRoute.Annotations, itg.Name()) { 85 | // If integration is ignored, skip it 86 | logger.Debug("ignoring integration", zap.String("integration", itg.Name())) 87 | continue 88 | } 89 | if err := itg.UpdateResource(ctx, &ingressRoute, info); err != nil { 90 | logger.Error("failed to upsert resource", 91 | zap.String("integration", itg.Name()), zap.Error(err), 92 | ) 93 | return ctrl.Result{}, err 94 | } 95 | logger.Debug("successfully upserted resource", zap.String("integration", itg.Name())) 96 | } 97 | 98 | logger.Info("ingress route is up to date") 99 | return ctrl.Result{}, nil 100 | } 101 | 102 | // SetupWithManager sets up the controller with the Manager. 103 | func (r *IngressRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { 104 | builder := ctrl.NewControllerManagedBy(mgr).For(&traefik.IngressRoute{}) 105 | builder = builderWithIntegrations(builder, r.integrations, r, r.logger) 106 | return builder.Complete(r) 107 | } 108 | -------------------------------------------------------------------------------- /internal/controllers/ingressroute_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | configv1 "github.com/borchero/switchboard/internal/config/v1" 9 | "github.com/borchero/switchboard/internal/k8tests" 10 | certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 11 | cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 15 | traefiktypes "github.com/traefik/traefik/v3/pkg/types" 16 | "go.uber.org/zap" 17 | v1 "k8s.io/api/core/v1" 18 | apierrors "k8s.io/apimachinery/pkg/api/errors" 19 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | "k8s.io/apimachinery/pkg/types" 21 | _ "k8s.io/client-go/plugin/pkg/client/auth" 22 | controllerruntime "sigs.k8s.io/controller-runtime" 23 | "sigs.k8s.io/controller-runtime/pkg/client" 24 | "sigs.k8s.io/external-dns/endpoint" 25 | ) 26 | 27 | func TestSimpleIngress(t *testing.T) { 28 | runTest(t, testCase{ 29 | Ingress: traefik.IngressRoute{ 30 | ObjectMeta: metav1.ObjectMeta{ 31 | Name: "my-ingress", 32 | }, 33 | Spec: traefik.IngressRouteSpec{ 34 | Routes: []traefik.Route{{ 35 | Kind: "Rule", 36 | Match: "Host(`www.example.com`)", 37 | Services: []traefik.Service{{ 38 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 39 | Name: "nginx", 40 | }, 41 | }}, 42 | }}, 43 | TLS: &traefik.TLS{ 44 | SecretName: "www-tls-certificate", 45 | }, 46 | }, 47 | }, 48 | DNSNames: []string{"www.example.com"}, 49 | }) 50 | } 51 | 52 | func TestIngressNoTLS(t *testing.T) { 53 | runTest(t, testCase{ 54 | Ingress: traefik.IngressRoute{ 55 | ObjectMeta: metav1.ObjectMeta{ 56 | Name: "my-ingress", 57 | }, 58 | Spec: traefik.IngressRouteSpec{ 59 | Routes: []traefik.Route{{ 60 | Kind: "Rule", 61 | Match: "Host(`www.example.com`)", 62 | Services: []traefik.Service{{ 63 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 64 | Name: "nginx", 65 | }, 66 | }}, 67 | }}, 68 | }, 69 | }, 70 | DNSNames: []string{"www.example.com"}, 71 | }) 72 | } 73 | 74 | func TestIngressNoTLSNoDNS(t *testing.T) { 75 | runTest(t, testCase{ 76 | Ingress: traefik.IngressRoute{ 77 | ObjectMeta: metav1.ObjectMeta{ 78 | Name: "my-ingress", 79 | }, 80 | Spec: traefik.IngressRouteSpec{ 81 | Routes: []traefik.Route{{ 82 | Kind: "Rule", 83 | Match: "PathPrefix(`/test`)", 84 | Services: []traefik.Service{{ 85 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 86 | Name: "nginx", 87 | }, 88 | }}, 89 | }}, 90 | }, 91 | }, 92 | DNSNames: []string{}, 93 | }) 94 | } 95 | 96 | func TestIngressCustomDNS(t *testing.T) { 97 | runTest(t, testCase{ 98 | Ingress: traefik.IngressRoute{ 99 | ObjectMeta: metav1.ObjectMeta{ 100 | Name: "my-ingress", 101 | }, 102 | Spec: traefik.IngressRouteSpec{ 103 | Routes: []traefik.Route{{ 104 | Kind: "Rule", 105 | Match: "Host(`example.com`)", 106 | Services: []traefik.Service{{ 107 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 108 | Name: "nginx", 109 | }, 110 | }}, 111 | }}, 112 | TLS: &traefik.TLS{ 113 | SecretName: "www-tls-certificate", 114 | Domains: []traefiktypes.Domain{{ 115 | Main: "example.net", 116 | SANs: []string{ 117 | "*.example.net", 118 | }, 119 | }}, 120 | }, 121 | }, 122 | }, 123 | DNSNames: []string{"example.net", "*.example.net"}, 124 | }) 125 | } 126 | 127 | func TestIngressMultipleRules(t *testing.T) { 128 | runTest(t, testCase{ 129 | Ingress: traefik.IngressRoute{ 130 | ObjectMeta: metav1.ObjectMeta{ 131 | Name: "my-ingress", 132 | }, 133 | Spec: traefik.IngressRouteSpec{ 134 | Routes: []traefik.Route{{ 135 | Kind: "Rule", 136 | Match: "Host(`example.com`, `www.example.com`)", 137 | Services: []traefik.Service{{ 138 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 139 | Name: "nginx", 140 | }, 141 | }}, 142 | }, { 143 | Kind: "Rule", 144 | Match: "Host(`v2.example.com`)", 145 | Services: []traefik.Service{{ 146 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 147 | Name: "nginx", 148 | }, 149 | }}, 150 | }}, 151 | TLS: &traefik.TLS{ 152 | SecretName: "www-tls-certificate", 153 | }, 154 | }, 155 | }, 156 | DNSNames: []string{"example.com", "www.example.com", "v2.example.com"}, 157 | }) 158 | } 159 | 160 | func TestIngressHeader(t *testing.T) { 161 | runTest(t, testCase{ 162 | Ingress: traefik.IngressRoute{ 163 | ObjectMeta: metav1.ObjectMeta{ 164 | Name: "my-ingress", 165 | }, 166 | Spec: traefik.IngressRouteSpec{ 167 | Routes: []traefik.Route{{ 168 | Kind: "Rule", 169 | Match: "Host(`argo.host.name`) && Header(`Content-Type`, `application/grpc`)", 170 | Services: []traefik.Service{{ 171 | LoadBalancerSpec: traefik.LoadBalancerSpec{ 172 | Name: "nginx", 173 | }, 174 | }}, 175 | }}, 176 | }, 177 | }, 178 | DNSNames: []string{"argo.host.name"}, 179 | }) 180 | } 181 | 182 | //------------------------------------------------------------------------------------------------- 183 | // TESTING UTILITIES 184 | //------------------------------------------------------------------------------------------------- 185 | 186 | type testCase struct { 187 | Ingress traefik.IngressRoute 188 | DNSNames []string 189 | } 190 | 191 | func runTest(t *testing.T, test testCase) { 192 | // Setup 193 | ctx := context.Background() 194 | scheme := k8tests.NewScheme() 195 | client := k8tests.NewClient(t, scheme) 196 | namespace, shutdown := k8tests.NewNamespace(ctx, t, client) 197 | defer shutdown() 198 | 199 | // Create objects and run reconciliation 200 | service := k8tests.DummyService("traefik", namespace, 80) 201 | err := client.Create(ctx, &service) 202 | require.Nil(t, err) 203 | 204 | test.Ingress.Namespace = namespace 205 | err = client.Create(ctx, &test.Ingress) 206 | require.Nil(t, err) 207 | 208 | config := createConfig(&service) 209 | runReconciliation(ctx, t, client, test.Ingress, config) 210 | 211 | // Check whether the outputs are valid 212 | // 1) Certificate 213 | certificateName := types.NamespacedName{ 214 | Name: fmt.Sprintf("%s-tls", test.Ingress.Name), 215 | Namespace: namespace, 216 | } 217 | var certificate certmanager.Certificate 218 | err = client.Get(ctx, certificateName, &certificate) 219 | if test.Ingress.Spec.TLS == nil { 220 | assert.True(t, apierrors.IsNotFound(err)) 221 | } else { 222 | assert.Nil(t, err) 223 | assert.ElementsMatch(t, test.DNSNames, certificate.Spec.DNSNames) 224 | assert.Equal(t, 225 | config.Integrations.CertManager.Template.Spec.IssuerRef.Kind, 226 | certificate.Spec.IssuerRef.Kind, 227 | ) 228 | assert.Equal(t, 229 | config.Integrations.CertManager.Template.Spec.IssuerRef.Name, 230 | certificate.Spec.IssuerRef.Name, 231 | ) 232 | assert.Equal(t, test.Ingress.Spec.TLS.SecretName, certificate.Spec.SecretName) 233 | } 234 | 235 | // 2) DNS records 236 | endpointName := types.NamespacedName{Name: test.Ingress.Name, Namespace: namespace} 237 | var endpoint endpoint.DNSEndpoint 238 | err = client.Get(ctx, endpointName, &endpoint) 239 | if len(test.DNSNames) == 0 { 240 | assert.True(t, apierrors.IsNotFound(err)) 241 | } else { 242 | assert.Nil(t, err) 243 | assert.Len(t, endpoint.Spec.Endpoints, len(test.DNSNames)) 244 | for _, ep := range endpoint.Spec.Endpoints { 245 | assert.Len(t, ep.Targets, 1) 246 | assert.Equal(t, service.Spec.ClusterIP, ep.Targets[0]) 247 | } 248 | } 249 | } 250 | 251 | //------------------------------------------------------------------------------------------------- 252 | // OBJECT CREATION 253 | //------------------------------------------------------------------------------------------------- 254 | 255 | func runReconciliation( 256 | ctx context.Context, 257 | t *testing.T, 258 | client client.Client, 259 | ingress traefik.IngressRoute, 260 | config configv1.Config, 261 | ) { 262 | reconciler, err := NewIngressRouteReconciler(client, zap.NewNop(), config) 263 | require.Nil(t, err) 264 | _, err = reconciler.Reconcile(ctx, controllerruntime.Request{ 265 | NamespacedName: types.NamespacedName{Name: ingress.Name, Namespace: ingress.Namespace}, 266 | }) 267 | require.Nil(t, err) 268 | } 269 | 270 | func createConfig(service *v1.Service) configv1.Config { 271 | return configv1.Config{ 272 | Integrations: configv1.IntegrationConfigs{ 273 | ExternalDNS: &configv1.ExternalDNSIntegrationConfig{ 274 | TargetService: &configv1.ServiceRef{ 275 | Name: service.Name, 276 | Namespace: service.Namespace, 277 | }, 278 | }, 279 | CertManager: &configv1.CertManagerIntegrationConfig{ 280 | Template: certmanager.Certificate{ 281 | Spec: certmanager.CertificateSpec{ 282 | IssuerRef: cmmeta.ObjectReference{ 283 | Kind: "ClusterIssuer", 284 | Name: "my-issuer", 285 | }, 286 | }, 287 | }, 288 | }, 289 | }, 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /internal/controllers/utils.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | 6 | configv1 "github.com/borchero/switchboard/internal/config/v1" 7 | "github.com/borchero/switchboard/internal/ext" 8 | "github.com/borchero/switchboard/internal/integrations" 9 | "github.com/borchero/switchboard/internal/k8s" 10 | "github.com/borchero/switchboard/internal/switchboard" 11 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 12 | "go.uber.org/zap" 13 | "sigs.k8s.io/controller-runtime/pkg/builder" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/handler" 16 | ) 17 | 18 | func integrationsFromConfig( 19 | config configv1.Config, client client.Client, 20 | ) ([]integrations.Integration, error) { 21 | result := make([]integrations.Integration, 0) 22 | externalDNS := config.Integrations.ExternalDNS 23 | if config.Integrations.ExternalDNS != nil { 24 | if (externalDNS.TargetService == nil) == (len(externalDNS.TargetIPs) == 0) { 25 | return nil, fmt.Errorf( 26 | "exactly one of `targetService` and `targetIPs` must be set for external-dns", 27 | ) 28 | } 29 | if externalDNS.TargetService != nil { 30 | result = append(result, integrations.NewExternalDNS( 31 | client, switchboard.NewServiceTarget( 32 | externalDNS.TargetService.Name, 33 | externalDNS.TargetService.Namespace, 34 | ), 35 | )) 36 | } else { 37 | result = append(result, integrations.NewExternalDNS( 38 | client, switchboard.NewStaticTarget(externalDNS.TargetIPs...), 39 | )) 40 | } 41 | } 42 | 43 | certManager := config.Integrations.CertManager 44 | if certManager != nil { 45 | result = append(result, integrations.NewCertManager(client, certManager.Template)) 46 | } 47 | return result, nil 48 | } 49 | 50 | func builderWithIntegrations( 51 | builder *builder.Builder, 52 | integrations []integrations.Integration, 53 | ctrlClient client.Client, 54 | logger *zap.Logger, 55 | ) *builder.Builder { 56 | // Reconcile whenever an owned resource of one of the integrations is modified 57 | for _, itg := range integrations { 58 | builder = builder.Owns(itg.OwnedResource()) 59 | } 60 | 61 | // Watch for dependent resources if required 62 | for _, itg := range integrations { 63 | if itg.WatchedObject() != nil { 64 | var list traefik.IngressRouteList 65 | enqueue := k8s.EnqueueMapFunc(ctrlClient, logger, itg.WatchedObject(), &list, 66 | func(list *traefik.IngressRouteList) []client.Object { 67 | return ext.Map(list.Items, func(v traefik.IngressRoute) client.Object { 68 | return &v 69 | }) 70 | }, 71 | ) 72 | builder = builder.Watches( 73 | itg.WatchedObject(), 74 | handler.EnqueueRequestsFromMapFunc(enqueue), 75 | ) 76 | } 77 | } 78 | 79 | return builder 80 | } 81 | -------------------------------------------------------------------------------- /internal/controllers/utils_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "testing" 5 | 6 | configv1 "github.com/borchero/switchboard/internal/config/v1" 7 | "github.com/borchero/switchboard/internal/k8tests" 8 | certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 9 | cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestIntegrationsFromConfig(t *testing.T) { 15 | // Setup 16 | scheme := k8tests.NewScheme() 17 | client := k8tests.NewClient(t, scheme) 18 | 19 | // Test all configurations of integrations 20 | var config configv1.Config 21 | 22 | integrations, err := integrationsFromConfig(config, client) 23 | require.Nil(t, err) 24 | assert.Len(t, integrations, 0) 25 | 26 | config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{ 27 | TargetService: &configv1.ServiceRef{Name: "my-service", Namespace: "my-namespace"}, 28 | } 29 | integrations, err = integrationsFromConfig(config, client) 30 | require.Nil(t, err) 31 | assert.Len(t, integrations, 1) 32 | assert.Equal(t, "external-dns", integrations[0].Name()) 33 | 34 | config.Integrations.ExternalDNS = nil 35 | config.Integrations.CertManager = &configv1.CertManagerIntegrationConfig{ 36 | Template: certmanager.Certificate{ 37 | Spec: certmanager.CertificateSpec{ 38 | IssuerRef: cmmeta.ObjectReference{ 39 | Kind: "ClusterIssuer", 40 | Name: "my-issuer", 41 | }, 42 | }, 43 | }, 44 | } 45 | integrations, err = integrationsFromConfig(config, client) 46 | require.Nil(t, err) 47 | assert.Len(t, integrations, 1) 48 | assert.Equal(t, "cert-manager", integrations[0].Name()) 49 | 50 | config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{ 51 | TargetIPs: []string{"127.0.0.1"}, 52 | } 53 | integrations, err = integrationsFromConfig(config, client) 54 | require.Nil(t, err) 55 | assert.Len(t, integrations, 2) 56 | 57 | // Must fail if external DNS is not configured correctly 58 | config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{} 59 | _, err = integrationsFromConfig(config, client) 60 | require.NotNil(t, err) 61 | 62 | config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{ 63 | TargetIPs: []string{}, 64 | } 65 | _, err = integrationsFromConfig(config, client) 66 | require.NotNil(t, err) 67 | } 68 | -------------------------------------------------------------------------------- /internal/ext/optionals.go: -------------------------------------------------------------------------------- 1 | package ext 2 | 3 | // AndThen applies the given function if the passed value is non-nil and returns `nil` otherwise. 4 | func AndThen[T any, V any](value *T, f func(T) V) *V { 5 | if value == nil { 6 | return nil 7 | } 8 | result := f(*value) 9 | return &result 10 | } 11 | -------------------------------------------------------------------------------- /internal/ext/optionals_test.go: -------------------------------------------------------------------------------- 1 | package ext 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAndThenNil(t *testing.T) { 10 | var test *int 11 | result := AndThen(test, func(v int) int { return v * 2 }) 12 | assert.Nil(t, result) 13 | } 14 | 15 | func TestAndThenNotNil(t *testing.T) { 16 | test := 2 17 | result := AndThen(&test, func(v int) int { return v * 2 }) 18 | expected := 4 19 | assert.Equal(t, &expected, result) 20 | } 21 | -------------------------------------------------------------------------------- /internal/ext/slices.go: -------------------------------------------------------------------------------- 1 | package ext 2 | 3 | // Map is a functional operator to map all values of a slice to a different type. 4 | func Map[T any, V any](values []T, f func(T) V) []V { 5 | result := make([]V, 0, len(values)) 6 | for _, val := range values { 7 | result = append(result, f(val)) 8 | } 9 | return result 10 | } 11 | -------------------------------------------------------------------------------- /internal/ext/slices_test.go: -------------------------------------------------------------------------------- 1 | package ext 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMap(t *testing.T) { 10 | values := []int{1, 2, 3} 11 | result := Map(values, func(v int) float32 { return float32(v) * 2.5 }) 12 | assert.ElementsMatch(t, result, []float32{2.5, 5.0, 7.5}) 13 | } 14 | -------------------------------------------------------------------------------- /internal/integrations/certmanager.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "dario.cat/mergo" 8 | "github.com/borchero/switchboard/internal/k8s" 9 | certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 13 | ) 14 | 15 | type certManager struct { 16 | client client.Client 17 | template certmanager.Certificate 18 | } 19 | 20 | // NewCertManager initializes a new cert-manager integration which creates certificates which use 21 | // the provided issuer. 22 | func NewCertManager(client client.Client, template certmanager.Certificate) Integration { 23 | return &certManager{client, template} 24 | } 25 | 26 | func (*certManager) Name() string { 27 | return "cert-manager" 28 | } 29 | 30 | func (*certManager) OwnedResource() client.Object { 31 | return &certmanager.Certificate{} 32 | } 33 | 34 | func (*certManager) WatchedObject() client.Object { 35 | return nil 36 | } 37 | 38 | func (c *certManager) UpdateResource( 39 | ctx context.Context, owner metav1.Object, info IngressInfo, 40 | ) error { 41 | // If the ingress does not specify a TLS secret name or specifies no hosts, no certificate 42 | // needs to be created. 43 | if info.TLSSecretName == nil || len(info.Hosts) == 0 { 44 | certificate := certmanager.Certificate{ObjectMeta: c.objectMeta(owner)} 45 | if err := k8s.DeleteIfFound(ctx, c.client, &certificate); err != nil { 46 | return fmt.Errorf("failed to delete TLS certificate: %w", err) 47 | } 48 | return nil 49 | } 50 | 51 | // Otherwise, we can create the certificate resource 52 | resource := certmanager.Certificate{ObjectMeta: c.objectMeta(owner)} 53 | if _, err := controllerutil.CreateOrPatch(ctx, c.client, &resource, func() error { 54 | // Meta 55 | if err := reconcileMetadata( 56 | owner, &resource, c.client.Scheme(), &c.template.ObjectMeta, 57 | ); err != nil { 58 | return fmt.Errorf("failed to reconcile metadata: %s", err) 59 | } 60 | 61 | // Spec 62 | template := c.template.Spec.DeepCopy() 63 | template.SecretName = *info.TLSSecretName 64 | template.DNSNames = info.Hosts 65 | if err := mergo.Merge(&resource.Spec, template, mergo.WithOverride); err != nil { 66 | return fmt.Errorf("failed to reconcile specification: %s", err) 67 | } 68 | return nil 69 | }); err != nil { 70 | return fmt.Errorf("failed to upsert TLS certificate: %w", err) 71 | } 72 | return nil 73 | } 74 | 75 | //------------------------------------------------------------------------------------------------- 76 | // UTILS 77 | //------------------------------------------------------------------------------------------------- 78 | 79 | func (*certManager) objectMeta(parent metav1.Object) metav1.ObjectMeta { 80 | return metav1.ObjectMeta{ 81 | Name: fmt.Sprintf("%s-tls", parent.GetName()), 82 | Namespace: parent.GetNamespace(), 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /internal/integrations/certmanager_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/borchero/switchboard/internal/k8tests" 9 | certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 10 | cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | func TestCertManagerUpdateResource(t *testing.T) { 17 | // Setup 18 | ctx := context.Background() 19 | scheme := k8tests.NewScheme() 20 | client := k8tests.NewClient(t, scheme) 21 | namespace, shutdown := k8tests.NewNamespace(ctx, t, client) 22 | defer shutdown() 23 | 24 | // Create a dummy service as owner 25 | owner := k8tests.DummyService("my-service", namespace, 80) 26 | err := client.Create(ctx, &owner) 27 | require.Nil(t, err) 28 | integration := NewCertManager(client, certmanager.Certificate{ 29 | Spec: certmanager.CertificateSpec{ 30 | IssuerRef: cmmeta.ObjectReference{ 31 | Kind: "ClusterIssuer", 32 | Name: "my-issuer", 33 | }, 34 | }, 35 | }) 36 | 37 | // Nothing should be created if no hosts or no tls is set 38 | tlsName := "test-tls" 39 | 40 | var info IngressInfo 41 | 42 | err = integration.UpdateResource(ctx, &owner, info) 43 | require.Nil(t, err) 44 | assert.Len(t, getCertificates(ctx, t, client, namespace), 0) 45 | 46 | info = IngressInfo{TLSSecretName: &tlsName} 47 | err = integration.UpdateResource(ctx, &owner, info) 48 | require.Nil(t, err) 49 | assert.Len(t, getCertificates(ctx, t, client, namespace), 0) 50 | 51 | info = IngressInfo{Hosts: []string{"example.com"}} 52 | err = integration.UpdateResource(ctx, &owner, info) 53 | require.Nil(t, err) 54 | assert.Len(t, getCertificates(ctx, t, client, namespace), 0) 55 | 56 | // If both are set, we should see a certificate created 57 | info = IngressInfo{Hosts: []string{"example.com"}, TLSSecretName: &tlsName} 58 | err = integration.UpdateResource(ctx, &owner, info) 59 | require.Nil(t, err) 60 | 61 | certificates := getCertificates(ctx, t, client, namespace) 62 | assert.Len(t, certificates, 1) 63 | assert.Equal(t, fmt.Sprintf("%s-tls", owner.Name), certificates[0].Name) 64 | assert.Equal(t, tlsName, certificates[0].Spec.SecretName) 65 | assert.Equal(t, "ClusterIssuer", certificates[0].Spec.IssuerRef.Kind) 66 | assert.Equal(t, "my-issuer", certificates[0].Spec.IssuerRef.Name) 67 | assert.ElementsMatch(t, info.Hosts, certificates[0].Spec.DNSNames) 68 | 69 | // We should see an update if we change any info 70 | info.Hosts = []string{"example.com", "www.example.com"} 71 | err = integration.UpdateResource(ctx, &owner, info) 72 | require.Nil(t, err) 73 | certificates = getCertificates(ctx, t, client, namespace) 74 | assert.Len(t, certificates, 1) 75 | assert.Equal(t, tlsName, certificates[0].Spec.SecretName) 76 | assert.ElementsMatch(t, info.Hosts, certificates[0].Spec.DNSNames) 77 | 78 | updatedTLSName := "new-test-tls" 79 | info.TLSSecretName = &updatedTLSName 80 | err = integration.UpdateResource(ctx, &owner, info) 81 | require.Nil(t, err) 82 | certificates = getCertificates(ctx, t, client, namespace) 83 | assert.Len(t, certificates, 1) 84 | assert.Equal(t, updatedTLSName, certificates[0].Spec.SecretName) 85 | assert.ElementsMatch(t, info.Hosts, certificates[0].Spec.DNSNames) 86 | 87 | // When no hosts are set, the certificate should be removed again 88 | info.Hosts = nil 89 | err = integration.UpdateResource(ctx, &owner, info) 90 | require.Nil(t, err) 91 | assert.Len(t, getCertificates(ctx, t, client, namespace), 0) 92 | } 93 | 94 | //------------------------------------------------------------------------------------------------- 95 | // UTILS 96 | //------------------------------------------------------------------------------------------------- 97 | 98 | func getCertificates( 99 | ctx context.Context, t *testing.T, ctrlClient client.Client, namespace string, 100 | ) []certmanager.Certificate { 101 | var list certmanager.CertificateList 102 | err := ctrlClient.List(ctx, &list, &client.ListOptions{ 103 | Namespace: namespace, 104 | }) 105 | require.Nil(t, err) 106 | return list.Items 107 | } 108 | -------------------------------------------------------------------------------- /internal/integrations/externaldns.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/asaskevich/govalidator" 8 | "github.com/borchero/switchboard/internal/k8s" 9 | "github.com/borchero/switchboard/internal/switchboard" 10 | v1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 14 | "sigs.k8s.io/external-dns/endpoint" 15 | ) 16 | 17 | type externalDNS struct { 18 | client client.Client 19 | target switchboard.Target 20 | ttl endpoint.TTL 21 | } 22 | 23 | // NewExternalDNS initializes a new external-dns integration whose created DNS endpoints target the 24 | // provided service. 25 | func NewExternalDNS(client client.Client, target switchboard.Target) Integration { 26 | return &externalDNS{client, target, 300} 27 | } 28 | 29 | func (*externalDNS) Name() string { 30 | return "external-dns" 31 | } 32 | 33 | func (*externalDNS) OwnedResource() client.Object { 34 | return &endpoint.DNSEndpoint{} 35 | } 36 | 37 | func (e *externalDNS) WatchedObject() client.Object { 38 | name := e.target.NamespacedName() 39 | if name == nil { 40 | return nil 41 | } 42 | return &v1.Service{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Name: name.Name, 45 | Namespace: name.Namespace, 46 | }, 47 | } 48 | } 49 | 50 | func (e *externalDNS) UpdateResource( 51 | ctx context.Context, owner metav1.Object, info IngressInfo, 52 | ) error { 53 | // If the ingress specifies no hosts, there should be no endpoint. We try deleting it and 54 | // ignore any error if it was not found. 55 | if len(info.Hosts) == 0 { 56 | dnsEndpoint := endpoint.DNSEndpoint{ObjectMeta: e.objectMeta(owner)} 57 | if err := k8s.DeleteIfFound(ctx, e.client, &dnsEndpoint); err != nil { 58 | return fmt.Errorf("failed to delete DNS endpoint: %w", err) 59 | } 60 | return nil 61 | } 62 | 63 | // Get the IPs of the target service 64 | targets, err := e.target.Targets(ctx, e.client) 65 | if err != nil { 66 | return fmt.Errorf("failed to query IP for DNS A record: %w", err) 67 | } 68 | 69 | // Create the endpoint resource 70 | resource := endpoint.DNSEndpoint{ObjectMeta: e.objectMeta(owner)} 71 | if _, err := controllerutil.CreateOrPatch(ctx, e.client, &resource, func() error { 72 | // Meta 73 | if err := reconcileMetadata(owner, &resource, e.client.Scheme()); err != nil { 74 | return nil 75 | } 76 | 77 | // Spec 78 | resource.Spec.Endpoints = e.endpoints(info.Hosts, targets) 79 | return nil 80 | }); err != nil { 81 | return fmt.Errorf("failed to upsert DNS endpoint: %w", err) 82 | } 83 | return nil 84 | } 85 | 86 | //------------------------------------------------------------------------------------------------- 87 | // UTILS 88 | //------------------------------------------------------------------------------------------------- 89 | 90 | func (*externalDNS) objectMeta(owner metav1.Object) metav1.ObjectMeta { 91 | return metav1.ObjectMeta{ 92 | Name: owner.GetName(), 93 | Namespace: owner.GetNamespace(), 94 | Annotations: owner.GetAnnotations(), 95 | } 96 | } 97 | 98 | func (e *externalDNS) endpoints(hosts []string, targets []string) []*endpoint.Endpoint { 99 | // Get the records for the target service 100 | targetRecords := make(map[string][]string) 101 | for _, target := range targets { 102 | rtype := e.recordType(target) 103 | targetRecords[rtype] = append(targetRecords[rtype], target) 104 | } 105 | 106 | // Create the endpoints 107 | endpoints := make([]*endpoint.Endpoint, 0, len(hosts)) 108 | for _, host := range hosts { 109 | for rtype, values := range targetRecords { 110 | endpoints = append(endpoints, &endpoint.Endpoint{ 111 | DNSName: host, 112 | Targets: values, 113 | RecordType: rtype, 114 | RecordTTL: e.ttl, 115 | }) 116 | } 117 | } 118 | return endpoints 119 | } 120 | 121 | func (*externalDNS) recordType(target string) string { 122 | if govalidator.IsIPv4(target) { 123 | return "A" 124 | } 125 | if govalidator.IsIPv6(target) { 126 | return "AAAA" 127 | } 128 | return "CNAME" 129 | } 130 | -------------------------------------------------------------------------------- /internal/integrations/externaldns_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/borchero/switchboard/internal/k8tests" 8 | "github.com/borchero/switchboard/internal/switchboard" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | "sigs.k8s.io/external-dns/endpoint" 13 | ) 14 | 15 | func TestExternalDNSWatchedObject(t *testing.T) { 16 | integration := NewExternalDNS(nil, switchboard.NewServiceTarget("my-name", "my-namespace")) 17 | obj := integration.WatchedObject() 18 | assert.Equal(t, "my-name", obj.GetName()) 19 | assert.Equal(t, "my-namespace", obj.GetNamespace()) 20 | } 21 | 22 | func TestExternalDNSUpdateResource(t *testing.T) { 23 | // Setup 24 | ctx := context.Background() 25 | scheme := k8tests.NewScheme() 26 | client := k8tests.NewClient(t, scheme) 27 | namespace, shutdown := k8tests.NewNamespace(ctx, t, client) 28 | defer shutdown() 29 | 30 | // Create a dummy service as owner and target 31 | owner := k8tests.DummyService("my-service", namespace, 80) 32 | err := client.Create(ctx, &owner) 33 | require.Nil(t, err) 34 | integration := NewExternalDNS(client, switchboard.NewServiceTarget(owner.Name, namespace)) 35 | 36 | // No resource should be created if no hosts are provided 37 | var info IngressInfo 38 | 39 | err = integration.UpdateResource(ctx, &owner, info) 40 | require.Nil(t, err) 41 | assert.Len(t, getDNSEndpoints(ctx, t, client, namespace), 0) 42 | 43 | // A resource with the name of the service should be created for at least one host 44 | info.Hosts = []string{"example.com"} 45 | err = integration.UpdateResource(ctx, &owner, info) 46 | require.Nil(t, err) 47 | endpoints := getDNSEndpoints(ctx, t, client, namespace) 48 | assert.Len(t, endpoints, 1) 49 | assert.Contains(t, endpoints, owner.Name) 50 | assert.ElementsMatch(t, endpoints[owner.Name], info.Hosts) 51 | 52 | // When the hosts are changed, more endpoints should be added 53 | info.Hosts = []string{"example.com", "www.example.com"} 54 | err = integration.UpdateResource(ctx, &owner, info) 55 | require.Nil(t, err) 56 | endpoints = getDNSEndpoints(ctx, t, client, namespace) 57 | assert.Len(t, endpoints, 1) 58 | assert.Contains(t, endpoints, owner.Name) 59 | assert.ElementsMatch(t, endpoints[owner.Name], info.Hosts) 60 | 61 | // When no hosts are set, the endpoints should be removed 62 | info.Hosts = nil 63 | err = integration.UpdateResource(ctx, &owner, info) 64 | require.Nil(t, err) 65 | assert.Len(t, getDNSEndpoints(ctx, t, client, namespace), 0) 66 | } 67 | 68 | func TestExternalDNSEndpoints(t *testing.T) { 69 | integration := externalDNS{ttl: 250} 70 | hosts := []string{"example.com", "www.example.com"} 71 | 72 | endpoints := integration.endpoints(hosts, []string{"127.0.0.1"}) 73 | assert.Len(t, endpoints, 2) 74 | for _, ep := range endpoints { 75 | assert.ElementsMatch(t, ep.Targets, []string{"127.0.0.1"}) 76 | assert.Equal(t, ep.RecordTTL, endpoint.TTL(250)) 77 | assert.Equal(t, ep.RecordType, "A") 78 | assert.Contains(t, hosts, ep.DNSName) 79 | } 80 | 81 | endpoints = integration.endpoints(hosts, []string{"2001:db8::1"}) 82 | assert.Len(t, endpoints, 2) 83 | for _, ep := range endpoints { 84 | assert.ElementsMatch(t, ep.Targets, []string{"2001:db8::1"}) 85 | assert.Equal(t, ep.RecordTTL, endpoint.TTL(250)) 86 | assert.Equal(t, ep.RecordType, "AAAA") 87 | assert.Contains(t, hosts, ep.DNSName) 88 | } 89 | 90 | endpoints = integration.endpoints(hosts, []string{"127.0.0.1", "2001:db8::1"}) 91 | assert.Len(t, endpoints, 4) 92 | for _, ep := range endpoints { 93 | if ep.RecordType == "A" { 94 | assert.ElementsMatch(t, ep.Targets, []string{"127.0.0.1"}) 95 | assert.Equal(t, ep.RecordTTL, endpoint.TTL(250)) 96 | assert.Contains(t, hosts, ep.DNSName) 97 | } else { 98 | assert.ElementsMatch(t, ep.Targets, []string{"2001:db8::1"}) 99 | assert.Equal(t, ep.RecordTTL, endpoint.TTL(250)) 100 | assert.Equal(t, ep.RecordType, "AAAA") 101 | assert.Contains(t, hosts, ep.DNSName) 102 | } 103 | } 104 | 105 | endpoints = integration.endpoints(hosts, []string{"example.lb.identifier.amazonaws.com"}) 106 | assert.Len(t, endpoints, 2) 107 | for _, ep := range endpoints { 108 | assert.ElementsMatch(t, ep.Targets, []string{"example.lb.identifier.amazonaws.com"}) 109 | assert.Equal(t, ep.RecordTTL, endpoint.TTL(250)) 110 | assert.Equal(t, ep.RecordType, "CNAME") 111 | assert.Contains(t, hosts, ep.DNSName) 112 | } 113 | } 114 | 115 | func TestExternalDNSRecordType(t *testing.T) { 116 | integration := externalDNS{ttl: 250} 117 | assert.Equal(t, "A", integration.recordType("127.0.0.1")) 118 | assert.Equal(t, "AAAA", integration.recordType("::FFFF:C0A8:1")) 119 | assert.Equal(t, "AAAA", integration.recordType("2001:db8::1")) 120 | assert.Equal(t, "CNAME", integration.recordType("example.lb.identifier.amazonaws.com")) 121 | } 122 | 123 | //------------------------------------------------------------------------------------------------- 124 | // UTILS 125 | //------------------------------------------------------------------------------------------------- 126 | 127 | func getDNSEndpoints( 128 | ctx context.Context, t *testing.T, ctrlClient client.Client, namespace string, 129 | ) map[string][]string { 130 | var list endpoint.DNSEndpointList 131 | err := ctrlClient.List(ctx, &list, &client.ListOptions{ 132 | Namespace: namespace, 133 | }) 134 | require.Nil(t, err) 135 | 136 | result := make(map[string][]string) 137 | for _, item := range list.Items { 138 | names := make([]string, 0) 139 | for _, ep := range item.Spec.Endpoints { 140 | names = append(names, ep.DNSName) 141 | } 142 | result[item.Name] = names 143 | } 144 | return result 145 | } 146 | -------------------------------------------------------------------------------- /internal/integrations/interface.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | ) 9 | 10 | const ( 11 | managedByLabelKey = "kubernetes.io/managed-by" 12 | ingressAnnotationKey = "kubernetes.io/ingress.class" 13 | ) 14 | 15 | // IngressInfo encapsulates information extracted from ingress objects that integrations act upon. 16 | type IngressInfo struct { 17 | Hosts []string 18 | TLSSecretName *string 19 | } 20 | 21 | // Integration is an interface for any component that allows to create "derivative" Kubernetes 22 | // resources for a Traefik ingress resources. An example is the external-dns integration which 23 | // generates DNSEndpoint resources for IngressRoute objects. 24 | type Integration interface { 25 | // Name returns a canonical name for this integration to identify it in logs. 26 | Name() string 27 | 28 | // OwnedResource returns the resource (i.e. CRD of an external tool) that this integration 29 | // owns. The resource should be "empty", i.e. no fields should be set. 30 | OwnedResource() client.Object 31 | 32 | // WatchedObject optionally returns a particular object whose changes require the 33 | // reconciliation of all resources that this integration is applied to. In contrast to 34 | // `OwnedResource`, this method returns a concrete object (i.e. its name and namespace must 35 | // set set). If the integration does not watch any resources, this method may return `nil`. 36 | WatchedObject() client.Object 37 | 38 | // UpdateResource updates the resource that ought to be owned by the passed object. Updating 39 | // may entail creating the resource, updating an existing resource, or deleting the resouce. 40 | // All information the generated resource is derived from the integration's global 41 | // configuration along with the given ingress information. 42 | UpdateResource(ctx context.Context, owner metav1.Object, info IngressInfo) error 43 | } 44 | -------------------------------------------------------------------------------- /internal/integrations/utils.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "dario.cat/mergo" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/runtime" 9 | ctrl "sigs.k8s.io/controller-runtime" 10 | ) 11 | 12 | func reconcileMetadata( 13 | owner metav1.Object, target metav1.Object, scheme *runtime.Scheme, sources ...metav1.Object, 14 | ) error { 15 | // Reconcile labels 16 | labels := defaultEmpty(target.GetLabels()) 17 | labels[managedByLabelKey] = "switchboard" 18 | for _, source := range sources { 19 | if err := mergo.MergeWithOverwrite(&labels, source.GetLabels()); err != nil { 20 | return fmt.Errorf("failed to update labels: %s", err) 21 | } 22 | } 23 | target.SetLabels(labels) 24 | 25 | // Reconcile annotations 26 | annotations := defaultEmpty(target.GetAnnotations()) 27 | if ingressClass, ok := owner.GetAnnotations()[ingressAnnotationKey]; ok { 28 | annotations[ingressAnnotationKey] = ingressClass 29 | } else { 30 | delete(annotations, ingressAnnotationKey) 31 | } 32 | for _, source := range sources { 33 | if err := mergo.MergeWithOverwrite(&annotations, source.GetAnnotations()); err != nil { 34 | return fmt.Errorf("failed to update annotations: %s", err) 35 | } 36 | } 37 | target.SetAnnotations(annotations) 38 | 39 | // Set controller reference 40 | if err := ctrl.SetControllerReference(owner, target, scheme); err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | 46 | func defaultEmpty(m map[string]string) map[string]string { 47 | if m == nil { 48 | return make(map[string]string) 49 | } 50 | return m 51 | } 52 | -------------------------------------------------------------------------------- /internal/integrations/utils_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/borchero/switchboard/internal/k8tests" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 12 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 13 | ) 14 | 15 | func TestReconcileMetadata(t *testing.T) { 16 | scheme := runtime.NewScheme() 17 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 18 | parent := k8tests.DummyService("my-name", "my-namespace", 80) 19 | 20 | // Check whether labels are set correctly 21 | target := k8tests.DummyService("your-name", "my-namespace", 8080) 22 | err := reconcileMetadata(&parent, &target, scheme) 23 | require.Nil(t, err) 24 | assert.Len(t, target.OwnerReferences, 1) 25 | assert.Len(t, target.Annotations, 0) 26 | assert.Len(t, target.Labels, 1) 27 | 28 | // Check whether annotations are copied correctly 29 | parent.Annotations = map[string]string{ 30 | ingressAnnotationKey: "test", 31 | "another.annotation": "hello", 32 | } 33 | target = k8tests.DummyService("your-name", "my-namespace", 8080) 34 | err = reconcileMetadata(&parent, &target, scheme) 35 | require.Nil(t, err) 36 | assert.Len(t, target.OwnerReferences, 1) 37 | assert.Len(t, target.Annotations, 1) 38 | assert.Len(t, target.Labels, 1) 39 | 40 | // Check whether additional annotations and labels are copied 41 | meta := metav1.ObjectMeta{ 42 | Labels: map[string]string{"my-label": "my-value"}, 43 | Annotations: map[string]string{"my-annotation-1": "1", "my-annotation-2": "2"}, 44 | } 45 | target = k8tests.DummyService("your-name", "my-namespace", 8080) 46 | err = reconcileMetadata(&parent, &target, scheme, &meta) 47 | require.Nil(t, err) 48 | assert.Len(t, target.OwnerReferences, 1) 49 | assert.Len(t, target.Annotations, 3) 50 | assert.Len(t, target.Labels, 2) 51 | } 52 | 53 | func TestDefaultEmpty(t *testing.T) { 54 | var m1 map[string]string 55 | assert.Nil(t, m1) 56 | assert.NotNil(t, defaultEmpty(m1)) 57 | assert.Len(t, defaultEmpty(m1), 0) 58 | 59 | m2 := map[string]string{"hello": "world"} 60 | assert.NotNil(t, m2) 61 | assert.NotNil(t, defaultEmpty(m2)) 62 | assert.Len(t, defaultEmpty(m2), 1) 63 | } 64 | -------------------------------------------------------------------------------- /internal/k8s/delete.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | apierrs "k8s.io/apimachinery/pkg/api/errors" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | ) 10 | 11 | // DeleteIfFound deletes the given resource from the cluster and returns an error only if the 12 | // deletion fails. If the resource does not exist, no error will be returned. 13 | func DeleteIfFound(ctx context.Context, client client.Client, resource client.Object) error { 14 | if err := client.Delete(ctx, resource); err != nil { 15 | if apierrs.IsNotFound(err) { 16 | return nil 17 | } 18 | return fmt.Errorf("failed to delete existing resource: %w", err) 19 | } 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /internal/k8s/delete_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/borchero/switchboard/internal/k8tests" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestDeleteIfFound(t *testing.T) { 13 | // Setup 14 | ctx := context.Background() 15 | scheme := k8tests.NewScheme() 16 | client := k8tests.NewClient(t, scheme) 17 | namespace, shutdown := k8tests.NewNamespace(ctx, t, client) 18 | defer shutdown() 19 | 20 | // Create a service 21 | service := k8tests.DummyService("my-service", namespace, 80) 22 | err := client.Create(ctx, &service) 23 | require.Nil(t, err) 24 | 25 | // Multiple deletes should not result in an error 26 | err = DeleteIfFound(ctx, client, &service) 27 | assert.Nil(t, err) 28 | 29 | err = DeleteIfFound(ctx, client, &service) 30 | assert.Nil(t, err) 31 | } 32 | -------------------------------------------------------------------------------- /internal/k8s/enqueue.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 9 | ) 10 | 11 | // EnqueueMapFunc may be used to watch changes of a particular target objects and trigger the 12 | // reconciliation of all resources of a particular type. The given logger is used to log errors in 13 | // the background. 14 | func EnqueueMapFunc[L client.ObjectList]( 15 | ctrlClient client.Client, 16 | logger *zap.Logger, 17 | target client.Object, 18 | list L, 19 | getItems func(L) []client.Object, 20 | ) func(context.Context, client.Object) []reconcile.Request { 21 | return func(ctx context.Context, obj client.Object) []reconcile.Request { 22 | // Check whether we need to enqueue any objects 23 | if target.GetObjectKind() != obj.GetObjectKind() || 24 | target.GetNamespace() != obj.GetNamespace() || 25 | target.GetName() != obj.GetName() { 26 | return nil 27 | } 28 | 29 | // If our filter matches, we want to fetch all items of the specified type... 30 | if err := ctrlClient.List(ctx, list); err != nil { 31 | logger.Error("failed to list resources upon object change", zap.Error(err)) 32 | return nil 33 | } 34 | 35 | // ...and map them to reconciliation requests 36 | items := getItems(list) 37 | requests := make([]reconcile.Request, len(items)) 38 | for i, item := range items { 39 | requests[i].Name = item.GetName() 40 | requests[i].Namespace = item.GetNamespace() 41 | } 42 | return requests 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/k8s/enqueue_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/borchero/switchboard/internal/ext" 8 | "github.com/borchero/switchboard/internal/k8tests" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "go.uber.org/zap" 12 | v1 "k8s.io/api/core/v1" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | func TestEnqueueMapFunc(t *testing.T) { 17 | // Setup 18 | ctx := context.Background() 19 | scheme := k8tests.NewScheme() 20 | ctrlClient := k8tests.NewClient(t, scheme) 21 | namespace, shutdown := k8tests.NewNamespace(ctx, t, ctrlClient) 22 | defer shutdown() 23 | 24 | // Create a couple of services 25 | service1 := k8tests.DummyService("my-service-1", namespace, 80) 26 | err := ctrlClient.Create(ctx, &service1) 27 | require.Nil(t, err) 28 | service2 := k8tests.DummyService("my-service-2", namespace, 80) 29 | err = ctrlClient.Create(ctx, &service2) 30 | require.Nil(t, err) 31 | service3 := k8tests.DummyService("my-service-3", namespace, 80) 32 | err = ctrlClient.Create(ctx, &service3) 33 | require.Nil(t, err) 34 | 35 | // Create the enqueue function which is triggered by service 1 36 | var services v1.ServiceList 37 | enqueuer := EnqueueMapFunc( 38 | ctrlClient, zap.NewNop(), &service1, &services, 39 | func(list *v1.ServiceList) []client.Object { 40 | return ext.Map(list.Items, func(v v1.Service) client.Object { return &v }) 41 | }, 42 | ) 43 | 44 | // Check whether enqueue only happens for service1 45 | assert.Greater(t, len(enqueuer(ctx, &service1)), 0) 46 | assert.Len(t, enqueuer(ctx, &service2), 0) 47 | assert.Len(t, enqueuer(ctx, &service3), 0) 48 | 49 | // Check whether distinct services are returned for enqueue 50 | names := []string{"my-service-1", "my-service-2", "my-service-3"} 51 | var found []string 52 | 53 | for _, obj := range enqueuer(ctx, &service1) { 54 | if obj.Namespace == namespace { 55 | found = append(found, obj.Name) 56 | } 57 | } 58 | assert.ElementsMatch(t, names, found) 59 | } 60 | -------------------------------------------------------------------------------- /internal/k8tests/client.go: -------------------------------------------------------------------------------- 1 | package k8tests 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | certmanager "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 9 | "github.com/stretchr/testify/require" 10 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | "k8s.io/apimachinery/pkg/runtime/schema" 14 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 15 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 16 | "k8s.io/client-go/tools/clientcmd" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | "sigs.k8s.io/external-dns/endpoint" 19 | ) 20 | 21 | // NewScheme returns a newly configured scheme which registers all types that are relevant for 22 | // Switchboard. 23 | func NewScheme() *runtime.Scheme { 24 | scheme := runtime.NewScheme() 25 | // >>> core types 26 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 27 | // >>> cert-manager 28 | utilruntime.Must(certmanager.AddToScheme(scheme)) 29 | // >>> traefik 30 | utilruntime.Must(traefik.AddToScheme(scheme)) 31 | // >>> external-dns 32 | groupVersion := schema.GroupVersion{Group: "externaldns.k8s.io", Version: "v1alpha1"} 33 | scheme.AddKnownTypes(groupVersion, 34 | &endpoint.DNSEndpoint{}, 35 | &endpoint.DNSEndpointList{}, 36 | ) 37 | metav1.AddToGroupVersion(scheme, groupVersion) 38 | return scheme 39 | } 40 | 41 | // NewClient returns a new Kubernetes client from the configuration available at ~/.kube/config. 42 | // The test fails if initialization fails. 43 | func NewClient(t *testing.T, scheme *runtime.Scheme) client.Client { 44 | configPath := filepath.Join(os.Getenv("HOME"), ".kube", "config") 45 | config, err := clientcmd.BuildConfigFromFlags("", configPath) 46 | require.Nil(t, err) 47 | client, err := client.New(config, client.Options{Scheme: scheme}) 48 | require.Nil(t, err) 49 | return client 50 | } 51 | -------------------------------------------------------------------------------- /internal/k8tests/dummies.go: -------------------------------------------------------------------------------- 1 | package k8tests 2 | 3 | import ( 4 | v1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // DummyService returns a dummy service with the specified name in the given namespace, using the 9 | // provided target port. 10 | func DummyService(name, namespace string, port int32) v1.Service { 11 | return v1.Service{ 12 | ObjectMeta: metav1.ObjectMeta{ 13 | Name: name, 14 | Namespace: namespace, 15 | }, 16 | Spec: v1.ServiceSpec{ 17 | Selector: map[string]string{ 18 | "app.kubernetes.io/name": "notfound", 19 | }, 20 | Ports: []v1.ServicePort{{ 21 | Port: port, 22 | Name: "http", 23 | }}, 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/k8tests/namespace.go: -------------------------------------------------------------------------------- 1 | package k8tests 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/require" 9 | v1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | ) 13 | 14 | // NewNamespace returns the (automatically generated) name of a newly created namespace along with 15 | // a shutdown function. If creating the namespace fails, the test is aborted. 16 | func NewNamespace(ctx context.Context, t *testing.T, client client.Client) (string, func()) { 17 | name := uuid.New() 18 | namespace := &v1.Namespace{ 19 | ObjectMeta: metav1.ObjectMeta{ 20 | Name: name.String(), 21 | }, 22 | } 23 | err := client.Create(ctx, namespace) 24 | require.Nil(t, err) 25 | return name.String(), func() { 26 | client.Delete(ctx, namespace) // nolint:errcheck 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/switchboard/hosts.go: -------------------------------------------------------------------------------- 1 | package switchboard 2 | 3 | import ( 4 | "fmt" 5 | 6 | muxer "github.com/traefik/traefik/v3/pkg/muxer/http" 7 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 8 | ) 9 | 10 | // HostCollection allows to aggregate the hosts from ingress resources. 11 | type HostCollection struct { 12 | hosts map[string]struct{} 13 | } 14 | 15 | // NewHostCollection returns a new "empty" host collection. 16 | func NewHostCollection() *HostCollection { 17 | return &HostCollection{hosts: make(map[string]struct{})} 18 | } 19 | 20 | // WithTLSHostsIfAvailable aggregates all hosts found in the provided TLS configuration. If the 21 | // TLS configuration is empty (i.e. `nil`), no hosts are extracted. This method should only be 22 | // called on a freshly initialized aggregator. 23 | func (a *HostCollection) WithTLSHostsIfAvailable(config *traefik.TLS) *HostCollection { 24 | if config != nil { 25 | for _, domain := range config.Domains { 26 | a.hosts[domain.Main] = struct{}{} 27 | for _, san := range domain.SANs { 28 | a.hosts[san] = struct{}{} 29 | } 30 | } 31 | } 32 | return a 33 | } 34 | 35 | // WithRouteHostsIfRequired aggregates all (unique) hosts found in the provided routes. If the 36 | // aggregator already manages at least one host, this method is a noop, regardless of the routes 37 | // passed as parameters. 38 | func (a *HostCollection) WithRouteHostsIfRequired( 39 | routes []traefik.Route, 40 | ) (*HostCollection, error) { 41 | if len(a.hosts) > 0 { 42 | return a, nil 43 | } 44 | for _, route := range routes { 45 | if route.Kind == "Rule" { 46 | hosts, err := muxer.ParseDomains(route.Match) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to parse domains: %s", err) 49 | } 50 | for _, host := range hosts { 51 | a.hosts[host] = struct{}{} 52 | } 53 | } 54 | } 55 | return a, nil 56 | } 57 | 58 | // Len returns the number of hosts that the aggregator currently manages. 59 | func (a *HostCollection) Len() int { 60 | return len(a.hosts) 61 | } 62 | 63 | // Hosts returns all hosts managed by this aggregator. 64 | func (a *HostCollection) Hosts() []string { 65 | hosts := make([]string, 0, len(a.hosts)) 66 | for host := range a.hosts { 67 | hosts = append(hosts, host) 68 | } 69 | return hosts 70 | } 71 | -------------------------------------------------------------------------------- /internal/switchboard/hosts_test.go: -------------------------------------------------------------------------------- 1 | package switchboard 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | traefik "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" 8 | traefiktypes "github.com/traefik/traefik/v3/pkg/types" 9 | ) 10 | 11 | func TestNewHostCollection(t *testing.T) { 12 | hosts := NewHostCollection() 13 | assert.Equal(t, hosts.Len(), 0) 14 | } 15 | 16 | func TestParseTLSHosts(t *testing.T) { 17 | hosts := NewHostCollection().WithTLSHostsIfAvailable(nil) 18 | assert.Equal(t, hosts.Len(), 0) 19 | 20 | hosts.WithTLSHostsIfAvailable(&traefik.TLS{ 21 | Domains: []traefiktypes.Domain{{ 22 | Main: "example.com", 23 | SANs: []string{"www.example.com"}, 24 | }}, 25 | }) 26 | assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com", "www.example.com"}) 27 | } 28 | 29 | func TestParseRouteHosts(t *testing.T) { 30 | hosts, err := NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ 31 | Kind: "Rule", 32 | Match: "Host(`example.com`)", 33 | }}) 34 | assert.Nil(t, err) 35 | assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) 36 | 37 | hosts, err = NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ 38 | Kind: "Rule", 39 | Match: "Host(`example.com`, `www.example.com`)", 40 | }}) 41 | assert.Nil(t, err) 42 | assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com", "www.example.com"}) 43 | 44 | hosts, err = NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ 45 | Kind: "Rule", 46 | Match: "Host(`example.com`, `www.example.com`)", 47 | }, { 48 | Kind: "Rule", 49 | Match: "Host(`v2.example.com`, `www.example.com`) && PathPrefix(`/test`)", 50 | }}) 51 | assert.Nil(t, err) 52 | assert.ElementsMatch( 53 | t, hosts.Hosts(), []string{"example.com", "www.example.com", "v2.example.com"}, 54 | ) 55 | 56 | hosts, err = NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ 57 | Kind: "Rule", 58 | Match: "Host(`service.namespace`, `service`)", 59 | }}) 60 | assert.Nil(t, err) 61 | assert.ElementsMatch(t, hosts.Hosts(), []string{"service.namespace", "service"}) 62 | } 63 | 64 | func TestParseRouteHostsNoop(t *testing.T) { 65 | hosts := NewHostCollection() 66 | hosts.hosts = map[string]struct{}{"example.com": {}} 67 | _, err := hosts.WithRouteHostsIfRequired([]traefik.Route{{ 68 | Kind: "Rule", 69 | Match: "Host(`www.example.com`)", 70 | }}) 71 | assert.Nil(t, err) 72 | assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) 73 | } 74 | -------------------------------------------------------------------------------- /internal/switchboard/selector.go: -------------------------------------------------------------------------------- 1 | package switchboard 2 | 3 | import "strings" 4 | 5 | const ( 6 | ingressAnnotationKey = "kubernetes.io/ingress.class" 7 | ignoreAnnotationKey = "switchboard.borchero.com/ignore" 8 | ) 9 | 10 | // Selector allows to easily determine if a resource with a set of annotations should be processed. 11 | type Selector struct { 12 | ingressClass *string 13 | } 14 | 15 | // NewSelector creates a new selector which selects resources with the 16 | // `kubernetes.io/ingress.class` set to the specified value if it is not `nil`. 17 | func NewSelector(ingressClass *string) Selector { 18 | return Selector{ingressClass} 19 | } 20 | 21 | // Matches determines whether the provided set of annotation match the selector. If the method 22 | // returns `true`, the resource from which the annotations were obtained should be processed. 23 | func (s Selector) Matches(annotations map[string]string) bool { 24 | // If the ignore annotation is set, selector never matches 25 | if ignore, ok := annotations[ignoreAnnotationKey]; ok { 26 | if ignore == "true" || ignore == "all" { 27 | return false 28 | } 29 | } 30 | 31 | // If the selector has an associated ingress class, the ingress class must match 32 | if s.ingressClass != nil { 33 | if ingressClass, ok := annotations[ingressAnnotationKey]; ok { 34 | return *s.ingressClass == ingressClass 35 | } 36 | // No ingress class present 37 | return false 38 | } 39 | 40 | // Otherwise, any ingress class is fine 41 | return true 42 | } 43 | 44 | // MatchesIntegration returns whether the provided set of annotations match the provided 45 | // integration. 46 | func (Selector) MatchesIntegration(annotations map[string]string, integration string) bool { 47 | if ignore, ok := annotations[ignoreAnnotationKey]; ok { 48 | if ignore == "true" || ignore == "all" { 49 | return false 50 | } 51 | // Iterate over list of values set for `ignore` annotation 52 | for _, ignored := range strings.Split(ignore, ",") { 53 | if strings.TrimSpace(ignored) == integration { 54 | return false 55 | } 56 | } 57 | } 58 | return true 59 | } 60 | -------------------------------------------------------------------------------- /internal/switchboard/selector_test.go: -------------------------------------------------------------------------------- 1 | package switchboard 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMatchesNoIngressClass(t *testing.T) { 10 | selector := NewSelector(nil) 11 | assert.True(t, selector.Matches(map[string]string{})) 12 | assert.True(t, selector.Matches(map[string]string{ 13 | "kubernetes.io/ingress.class": "test", 14 | })) 15 | assert.False(t, selector.Matches(map[string]string{ 16 | "switchboard.borchero.com/ignore": "true", 17 | })) 18 | assert.False(t, selector.Matches(map[string]string{ 19 | "kubernetes.io/ingress.class": "test", 20 | "switchboard.borchero.com/ignore": "true", 21 | })) 22 | assert.False(t, selector.Matches(map[string]string{ 23 | "switchboard.borchero.com/ignore": "all", 24 | })) 25 | assert.False(t, selector.Matches(map[string]string{ 26 | "kubernetes.io/ingress.class": "test", 27 | "switchboard.borchero.com/ignore": "all", 28 | })) 29 | } 30 | 31 | func TestMatchesIngressClass(t *testing.T) { 32 | cls := "ingress" 33 | selector := NewSelector(&cls) 34 | assert.False(t, selector.Matches(map[string]string{})) 35 | assert.False(t, selector.Matches(map[string]string{ 36 | "kubernetes.io/ingress.class": "test", 37 | })) 38 | assert.True(t, selector.Matches(map[string]string{ 39 | "kubernetes.io/ingress.class": "ingress", 40 | })) 41 | assert.False(t, selector.Matches(map[string]string{ 42 | "switchboard.borchero.com/ignore": "true", 43 | })) 44 | assert.False(t, selector.Matches(map[string]string{ 45 | "kubernetes.io/ingress.class": "test", 46 | "switchboard.borchero.com/ignore": "true", 47 | })) 48 | assert.False(t, selector.Matches(map[string]string{ 49 | "kubernetes.io/ingress.class": "ingress", 50 | "switchboard.borchero.com/ignore": "true", 51 | })) 52 | assert.False(t, selector.Matches(map[string]string{ 53 | "switchboard.borchero.com/ignore": "all", 54 | })) 55 | assert.False(t, selector.Matches(map[string]string{ 56 | "kubernetes.io/ingress.class": "test", 57 | "switchboard.borchero.com/ignore": "all", 58 | })) 59 | assert.False(t, selector.Matches(map[string]string{ 60 | "kubernetes.io/ingress.class": "ingress", 61 | "switchboard.borchero.com/ignore": "all", 62 | })) 63 | } 64 | 65 | func TestMatchesIntegration(t *testing.T) { 66 | cls := "ingress" 67 | selector := NewSelector(&cls) 68 | 69 | // Ignore all 70 | assert.False(t, selector.MatchesIntegration(map[string]string{ 71 | "switchboard.borchero.com/ignore": "true", 72 | }, "external-dns")) 73 | assert.False(t, selector.MatchesIntegration(map[string]string{ 74 | "switchboard.borchero.com/ignore": "all", 75 | }, "external-dns")) 76 | 77 | // Ignore only one 78 | assert.False(t, selector.MatchesIntegration(map[string]string{ 79 | "switchboard.borchero.com/ignore": "external-dns", 80 | }, "external-dns")) 81 | assert.True(t, selector.MatchesIntegration(map[string]string{ 82 | "switchboard.borchero.com/ignore": "cert-manager", 83 | }, "external-dns")) 84 | 85 | // Ignore multiple 86 | assert.False(t, selector.MatchesIntegration(map[string]string{ 87 | "switchboard.borchero.com/ignore": "external-dns,cert-manager", 88 | }, "external-dns")) 89 | assert.False(t, selector.MatchesIntegration(map[string]string{ 90 | "switchboard.borchero.com/ignore": "external-dns,cert-manager", 91 | }, "cert-manager")) 92 | assert.True(t, selector.MatchesIntegration(map[string]string{ 93 | "switchboard.borchero.com/ignore": "external-dns,cert-manager", 94 | }, "unknown")) 95 | 96 | // Ignore with space in between 97 | assert.False(t, selector.MatchesIntegration(map[string]string{ 98 | "switchboard.borchero.com/ignore": "external-dns, cert-manager", 99 | }, "external-dns")) 100 | assert.False(t, selector.MatchesIntegration(map[string]string{ 101 | "switchboard.borchero.com/ignore": "external-dns, cert-manager", 102 | }, "cert-manager")) 103 | assert.True(t, selector.MatchesIntegration(map[string]string{ 104 | "switchboard.borchero.com/ignore": "external-dns, cert-manager", 105 | }, "unknown")) 106 | } 107 | -------------------------------------------------------------------------------- /internal/switchboard/targets.go: -------------------------------------------------------------------------------- 1 | package switchboard 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/types" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | ) 11 | 12 | // Target is a type which allows to retrieve a potentially dynamically changing IP from Kubernetes. 13 | type Target interface { 14 | // Targets returns the IPv4/IPv6 addresses or hostnames that should be used as targets or an 15 | // error if the addresses/hostnames cannot be retrieved. 16 | Targets(ctx context.Context, client client.Client) ([]string, error) 17 | // NamespacedName returns the namespaced name of the dynamic target service or none if the IP 18 | // is not retrieved dynamically. 19 | NamespacedName() *types.NamespacedName 20 | } 21 | 22 | //------------------------------------------------------------------------------------------------- 23 | // SERVICE TARGET 24 | //------------------------------------------------------------------------------------------------- 25 | 26 | type serviceTarget struct { 27 | name types.NamespacedName 28 | } 29 | 30 | // NewServiceTarget creates a new target which dynamically sources the IP from the provided 31 | // Kubernetes service. 32 | func NewServiceTarget(name, namespace string) Target { 33 | return serviceTarget{ 34 | name: types.NamespacedName{Name: name, Namespace: namespace}, 35 | } 36 | } 37 | 38 | func (t serviceTarget) Targets(ctx context.Context, client client.Client) ([]string, error) { 39 | // Get service 40 | var service v1.Service 41 | if err := client.Get(ctx, t.name, &service); err != nil { 42 | return nil, fmt.Errorf("failed to query service: %w", err) 43 | } 44 | return t.targetsFromService(service), nil 45 | } 46 | 47 | func (serviceTarget) targetsFromService(service v1.Service) []string { 48 | // Try to get load balancer IPs/hostnames... 49 | targets := make([]string, 0) 50 | for _, ingress := range service.Status.LoadBalancer.Ingress { 51 | if ingress.Hostname != "" { 52 | // We cannot have more than one CNAME record, the hostname overwrites everything 53 | targets = []string{ingress.Hostname} 54 | break 55 | } 56 | if ingress.IP != "" { 57 | targets = append(targets, ingress.IP) 58 | } 59 | } 60 | 61 | // ...fall back to cluster IPs 62 | if len(targets) == 0 { 63 | targets = append(targets, service.Spec.ClusterIPs...) 64 | } 65 | return targets 66 | } 67 | 68 | func (t serviceTarget) NamespacedName() *types.NamespacedName { 69 | return &t.name 70 | } 71 | 72 | //------------------------------------------------------------------------------------------------- 73 | // STATIC TARGET 74 | //------------------------------------------------------------------------------------------------- 75 | 76 | type staticTarget struct { 77 | ips []string 78 | } 79 | 80 | // NewStaticTarget creates a new target which provides the given static IPs. IPs may be IPv4 or 81 | // IPv6 addresses (and any combination thereof). 82 | func NewStaticTarget(ips ...string) Target { 83 | return staticTarget{ips} 84 | } 85 | 86 | func (t staticTarget) Targets(ctx context.Context, client client.Client) ([]string, error) { 87 | return t.ips, nil 88 | } 89 | 90 | func (staticTarget) NamespacedName() *types.NamespacedName { 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/switchboard/targets_test.go: -------------------------------------------------------------------------------- 1 | package switchboard 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/borchero/switchboard/internal/k8tests" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | v1 "k8s.io/api/core/v1" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | func TestServiceTargetTargets(t *testing.T) { 16 | // Setup 17 | ctx := context.Background() 18 | scheme := k8tests.NewScheme() 19 | ctrlClient := k8tests.NewClient(t, scheme) 20 | namespace, shutdown := k8tests.NewNamespace(ctx, t, ctrlClient) 21 | defer shutdown() 22 | 23 | // Create a new service 24 | service := k8tests.DummyService("my-service", namespace, 80) 25 | err := ctrlClient.Create(ctx, &service) 26 | require.Nil(t, err) 27 | 28 | // Check whether we find the cluster IP 29 | target := NewServiceTarget(service.Name, service.Namespace) 30 | targets, err := target.Targets(ctx, ctrlClient) 31 | require.Nil(t, err) 32 | assert.ElementsMatch(t, service.Spec.ClusterIPs, targets) 33 | 34 | // Update the service to provide a load balancer 35 | service.Spec.Type = "LoadBalancer" 36 | err = ctrlClient.Update(ctx, &service) 37 | require.Nil(t, err) 38 | 39 | // Check whether we find the load balancer IP 40 | time.Sleep(10 * time.Second) 41 | name := client.ObjectKeyFromObject(&service) 42 | err = ctrlClient.Get(ctx, name, &service) 43 | require.Nil(t, err) 44 | targets, err = target.Targets(ctx, ctrlClient) 45 | require.Nil(t, err) 46 | assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, targets) 47 | } 48 | 49 | func TestServiceTargetTargetsFromService(t *testing.T) { 50 | var target serviceTarget 51 | 52 | service := v1.Service{ 53 | Spec: v1.ServiceSpec{ClusterIPs: []string{"10.0.0.5"}}, 54 | } 55 | 56 | // Source cluster IP 57 | targets := target.targetsFromService(service) 58 | assert.ElementsMatch(t, service.Spec.ClusterIPs, targets) 59 | 60 | // Source multiple cluster IPs 61 | service.Spec.ClusterIPs = []string{"10.0.0.5", "2001:db8::1"} 62 | targets = target.targetsFromService(service) 63 | assert.ElementsMatch(t, service.Spec.ClusterIPs, targets) 64 | 65 | // Source IP from status 66 | service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{ 67 | IP: "192.168.5.5", 68 | }} 69 | targets = target.targetsFromService(service) 70 | assert.ElementsMatch(t, []string{"192.168.5.5"}, targets) 71 | 72 | // Source hostname from status 73 | service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{ 74 | Hostname: "example.lb.identifier.amazonaws.com", 75 | }} 76 | targets = target.targetsFromService(service) 77 | assert.ElementsMatch(t, []string{"example.lb.identifier.amazonaws.com"}, targets) 78 | 79 | // Ensure hostname takes precedence 80 | service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{ 81 | IP: "192.168.5.5", 82 | Hostname: "example.lb.identifier.amazonaws.com", 83 | }} 84 | targets = target.targetsFromService(service) 85 | assert.ElementsMatch(t, []string{"example.lb.identifier.amazonaws.com"}, targets) 86 | 87 | // Ensure only one hostname 88 | service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{ 89 | Hostname: "example.lb.identifier.amazonaws.com", 90 | }, { 91 | Hostname: "example2.lb.identifier.amazonaws.com", 92 | }} 93 | targets = target.targetsFromService(service) 94 | assert.ElementsMatch(t, []string{"example.lb.identifier.amazonaws.com"}, targets) 95 | } 96 | 97 | func TestServiceTargetNamespacedName(t *testing.T) { 98 | target := NewServiceTarget("my-service", "my-namespace") 99 | name := target.NamespacedName() 100 | assert.Equal(t, "my-service", name.Name) 101 | assert.Equal(t, "my-namespace", name.Namespace) 102 | } 103 | 104 | func TestStaticTargetIPs(t *testing.T) { 105 | ctx := context.Background() 106 | expectedIPs := []string{"127.0.0.1", "2001:db8::1"} 107 | target := NewStaticTarget(expectedIPs...) 108 | ips, err := target.Targets(ctx, nil) 109 | require.Nil(t, err) 110 | assert.ElementsMatch(t, expectedIPs, ips) 111 | } 112 | -------------------------------------------------------------------------------- /pixi.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | channels = ["conda-forge"] 3 | name = "switchboard" 4 | platforms = ["linux-64", "osx-arm64"] 5 | 6 | [dependencies] 7 | bats-core = "*" 8 | helm-docs = "*" 9 | kubernetes-client = "*" 10 | kubernetes-cloud-provider-kind = "*" 11 | kubernetes-helm = "*" 12 | kubernetes-kind = "*" 13 | sed = "*" 14 | yq = "*" 15 | 16 | [feature.lint.dependencies] 17 | golangci-lint = "*" 18 | pre-commit = "*" 19 | pre-commit-hooks = "*" 20 | 21 | [environments] 22 | default = ["lint"] 23 | 24 | # ----------------------------------------------------------------------------------------------- # 25 | # TASKS # 26 | # ----------------------------------------------------------------------------------------------- # 27 | 28 | [tasks] 29 | docs = { cwd = "chart", cmd = """ 30 | helm-docs 31 | && sed -i -E '/cert-manager\\.installCRDs/d' README.md 32 | && sed -i -E '/external-dns\\.crd/d' README.md 33 | && sed -i -E '/external-dns\\.sources/d' README.md 34 | """ } 35 | lint = "golangci-lint run ./..." 36 | run-controller = "go run cmd/main.go --config dev/config.yaml" 37 | 38 | # ------------------------------------------- CLUSTER ------------------------------------------- # 39 | 40 | cluster-create = """ 41 | docker run -d --restart=always -p "127.0.0.1:5001:5000" --network bridge --name "kind-registry" registry:2 42 | && kind create cluster --name switchboard --config tests/config/kind.yaml 43 | && kubectl config use-context kind-switchboard 44 | && bash tests/scripts/connect-registry.sh 45 | && kubectl apply -f tests/config/registry.yaml 46 | """ 47 | cluster-lb-controller = "sudo cloud-provider-kind" 48 | cluster-setup = """ 49 | kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml 50 | && kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml 51 | && helm repo add bitnami https://charts.bitnami.com/bitnami 52 | && helm upgrade --install --set crd.create=true --wait external-dns bitnami/external-dns --timeout 10m 53 | && kubectl apply -f dev/manifests/ca-secret.yaml 54 | && kubectl apply -f dev/manifests/tls-issuer.yaml 55 | """ 56 | cluster-teardown = """ 57 | kind delete cluster --name switchboard 58 | && docker container rm -f kind-registry 59 | """ 60 | 61 | # -------------------------------------------- TESTS -------------------------------------------- # 62 | 63 | [tasks.test-coverage] 64 | cmd = "go test ./... -coverprofile cover.out" 65 | 66 | [tasks.test-e2e] 67 | args = ["image_name", "image_tag"] 68 | cmd = """ 69 | yq -yi '.image.name = \"{{ image_name }}\" | .image.tag = \"{{ image_tag }}\"' tests/config/switchboard.yaml 70 | && bats tests -t 71 | """ 72 | -------------------------------------------------------------------------------- /tests/config/kind.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kind.x-k8s.io/v1alpha4 2 | kind: Cluster 3 | containerdConfigPatches: 4 | # Allow to connect to a local registry 5 | - |- 6 | [plugins."io.containerd.grpc.v1.cri".registry] 7 | config_path = "/etc/containerd/certs.d" 8 | -------------------------------------------------------------------------------- /tests/config/registry.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: local-registry-hosting 5 | namespace: kube-public 6 | data: 7 | localRegistryHosting.v1: | 8 | host: "localhost:5001" 9 | help: "https://kind.sigs.k8s.io/docs/user/local-registry/" 10 | -------------------------------------------------------------------------------- /tests/config/switchboard.yaml: -------------------------------------------------------------------------------- 1 | integrations: 2 | certManager: 3 | enabled: true 4 | certificateTemplate: 5 | spec: 6 | issuerRef: 7 | kind: ClusterIssuer 8 | name: my-issuer 9 | externalDNS: 10 | enabled: true 11 | targetService: 12 | name: kube-dns 13 | namespace: kube-system 14 | external-dns: 15 | install: true 16 | cert-manager: 17 | install: true 18 | image: 19 | name: localhost:5001/switchboard 20 | tag: dev 21 | -------------------------------------------------------------------------------- /tests/deployment.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load lib/helpers 4 | 5 | @test "check deployment running" { 6 | install 7 | sleep 3 8 | 9 | POD_NAME=$( 10 | kubectl get pod -l app.kubernetes.io/name=switchboard \ 11 | -o jsonpath="{.items[0].metadata.name}" 12 | ) 13 | await_pod_ready $POD_NAME 14 | await_pod_running $POD_NAME 15 | } 16 | 17 | @test "check resources created" { 18 | kubectl apply -f ${BATS_TEST_DIRNAME}/resources/ingress.yaml 19 | # Wait for the Switchboard manager to pick up the changes 20 | sleep 0.3 21 | expect_resource_exists dnsendpoint my-ingress 22 | expect_resource_exists certificate my-ingress-tls 23 | } 24 | 25 | @test "check resources deleted" { 26 | kubectl delete ingressroute my-ingress 27 | # Wait for the Switchboard manager to pick up the changes 28 | sleep 0.3 29 | expect_resource_not_exists dnsendpoint my-ingress 30 | expect_resource_not_exists certificate my-ingress-tls 31 | } 32 | 33 | function teardown_suite() { 34 | uninstall 35 | } 36 | -------------------------------------------------------------------------------- /tests/lib/helpers.bash: -------------------------------------------------------------------------------- 1 | function install() { 2 | helm dependency build ${BATS_TEST_DIRNAME}/../chart 3 | helm install \ 4 | --values ${BATS_TEST_DIRNAME}/config/switchboard.yaml \ 5 | switchboard ${BATS_TEST_DIRNAME}/../chart 6 | } 7 | 8 | function uninstall() { 9 | helm uninstall switchboard 10 | } 11 | 12 | function await_pod_ready() { 13 | POD_NAME=$1 14 | 15 | check() { 16 | kubectl get pod $1 -o json | \ 17 | jq -r 'select( 18 | .status.phase == "Running" 19 | and ( 20 | [ .status.conditions[] | select(.type == "Ready" and .status == "True") ] 21 | | length 22 | ) == 1 23 | )' 24 | } 25 | 26 | for i in `seq 60`; do 27 | if [ -n "$(check ${POD_NAME})" ]; then 28 | echo "${POD_NAME} is ready." 29 | return 0 30 | fi 31 | sleep 2 32 | done 33 | 34 | echo "${POD_NAME} never became ready." 35 | return 1 36 | } 37 | 38 | function await_pod_running() { 39 | POD_NAME=$1 40 | 41 | check() { 42 | kubectl get pod $1 -o json | \ 43 | jq -r 'select( 44 | .status.phase == "Running" 45 | and ([ 46 | .status.conditions[] 47 | | select(.type == "Initialized" and .status == "True") 48 | ] | length) == 1 49 | )' 50 | } 51 | 52 | for i in `seq 60`; do 53 | if [ -n "$(check ${POD_NAME})" ]; then 54 | echo "${POD_NAME} is running." 55 | return 0 56 | fi 57 | sleep 2 58 | done 59 | 60 | echo "${POD_NAME} never became running." 61 | return 1 62 | } 63 | 64 | function expect_resource_exists() { 65 | RESOURCE_TYPE=$1 66 | RESOURCE_NAME=$2 67 | kubectl get $RESOURCE_TYPE $RESOURCE_NAME 68 | } 69 | 70 | function expect_resource_not_exists() { 71 | RESOURCE_TYPE=$1 72 | RESOURCE_NAME=$2 73 | kubectl get $RESOURCE_TYPE $RESOURCE_NAME && return 1 || return 0 74 | } 75 | -------------------------------------------------------------------------------- /tests/resources/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: traefik.io/v1alpha1 2 | kind: IngressRoute 3 | metadata: 4 | name: my-ingress 5 | spec: 6 | routes: 7 | - kind: Rule 8 | match: Host(`www.example.com`) 9 | services: 10 | - name: nginx 11 | tls: 12 | secretName: www-tls-certificate 13 | -------------------------------------------------------------------------------- /tests/scripts/connect-registry.sh: -------------------------------------------------------------------------------- 1 | # 1) Add the registry configuration to the kind cluster nodes 2 | REGISTRY_DIR="/etc/containerd/certs.d/localhost:5001" 3 | for node in $(kind get nodes --name switchboard); do 4 | docker exec "${node}" mkdir -p "${REGISTRY_DIR}" 5 | cat <