├── .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 | 
4 | [](https://artifacthub.io/packages/search?repo=switchboard)
5 | [](https://github.com/borchero/switchboard/actions/workflows/ci-application.yml)
6 | [](https://github.com/borchero/switchboard/actions/workflows/ci-chart.yml)
7 | [](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 | 
4 | [](https://artifacthub.io/packages/search?repo=switchboard)
5 | 
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 | 
4 | [](https://artifacthub.io/packages/search?repo=switchboard)
5 | 
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 <