├── .githooks └── pre-push ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── config.yaml │ └── feature.yaml ├── renovate.json ├── semantic.yml └── workflows │ ├── main.yml │ ├── release.yaml │ └── semgrep.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── CODEOWNERS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── cmd └── webhook │ ├── cmd │ └── root.go │ └── main.go ├── go.mod ├── go.sum ├── internal └── stackitprovider │ ├── apply_changes.go │ ├── apply_changes_test.go │ ├── config.go │ ├── domain_filter.go │ ├── domain_filter_test.go │ ├── helper.go │ ├── helper_test.go │ ├── models.go │ ├── records.go │ ├── records_test.go │ ├── rrset_fetcher.go │ ├── stackit.go │ ├── stackit_test.go │ └── zone_fetcher.go ├── licenses └── licenses-ignore-list.txt └── pkg ├── api ├── adjust_endpoints.go ├── adjust_endpoints_test.go ├── api.go ├── api_test.go ├── apply_changes.go ├── apply_changes_test.go ├── domain_filter.go ├── domain_filter_test.go ├── health.go ├── health_test.go ├── metrics.go ├── metrics_test.go ├── mock │ └── api.go ├── models.go ├── records.go ├── records_test.go └── webhook.go ├── metrics ├── http_middleware.go └── mock │ └── http_middleware.go └── stackit ├── options.go └── options_test.go /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | name: Semgrep 2 | 3 | on: 4 | # Scan changed files in PRs, block on new issues only (existing issues ignored) 5 | pull_request: {} 6 | 7 | jobs: 8 | semgrep: 9 | name: Scan 10 | runs-on: ubuntu-latest 11 | # Skip any PR created by dependabot to avoid permission issues 12 | if: (github.actor != 'dependabot[bot]') 13 | steps: 14 | # Fetch project source 15 | - uses: actions/checkout@v3 16 | 17 | - uses: returntocorp/semgrep-action@v1 18 | with: 19 | config: >- # more at semgrep.dev/explore 20 | p/security-audit 21 | p/secrets 22 | p/ci 23 | p/r2c 24 | p/r2c-ci 25 | p/docker 26 | p/dockerfile 27 | p/command-injection 28 | generateSarif: "1" 29 | 30 | # Upload findings to GitHub Advanced Security Dashboard [step 2/2] 31 | - name: Upload SARIF file for GitHub Advanced Security Dashboard 32 | uses: github/codeql-action/upload-sarif@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3 33 | with: 34 | sarif_file: semgrep.sarif 35 | if: always() 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: [ bug, triage ] 4 | assignees: 5 | - patrickkoss 6 | - Slm0n87 7 | - mgalm 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | Thanks for taking the time to fill out this bug report! Please fill the form below. 13 | - type: textarea 14 | id: what-happened 15 | attributes: 16 | label: What happened? 17 | description: Also tell us, what did you expect to happen? 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: reproducible 22 | attributes: 23 | label: How can we reproduce this? 24 | description: Please share as much information as possible. Logs, screenshots, etc. 25 | validations: 26 | required: true 27 | - type: checkboxes 28 | id: search 29 | attributes: 30 | label: Search 31 | options: 32 | - label: I did search for other open and closed issues before opening this. 33 | required: true 34 | - type: checkboxes 35 | id: terms 36 | attributes: 37 | label: Code of Conduct 38 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/SchwarzIT/.github/blob/main/CODE_OF_CONDUCT.md) 39 | options: 40 | - label: I agree to follow this project's Code of Conduct 41 | required: true 42 | - type: textarea 43 | id: ctx 44 | attributes: 45 | label: Additional context 46 | description: Anything else you would like to add 47 | validations: 48 | required: false 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a new feature and/or enhancement to an existing feature 3 | labels: [enhancement, triage] 4 | assignees: 5 | - patrickkoss 6 | - mgalm 7 | - Slm0n87 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | Thanks for taking the time to fill out this feature request! Please fill the form below. 13 | - type: textarea 14 | id: is-it-a-problem 15 | attributes: 16 | label: Is your feature request related to a problem? Please describe. 17 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: solution 22 | attributes: 23 | label: Describe the solution you'd like 24 | description: A clear and concise description of what you want to happen. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: alternatives 29 | attributes: 30 | label: Describe alternatives you've considered 31 | description: A clear and concise description of any alternative solutions or features you've considered. 32 | validations: 33 | required: true 34 | - type: checkboxes 35 | id: search 36 | attributes: 37 | label: Search 38 | options: 39 | - label: I did search for other open and closed issues before opening this. 40 | required: true 41 | - type: checkboxes 42 | id: terms 43 | attributes: 44 | label: Code of Conduct 45 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/SchwarzIT/.github/blob/main/CODE_OF_CONDUCT.md) 46 | options: 47 | - label: I agree to follow this project's Code of Conduct 48 | required: true 49 | - type: textarea 50 | id: ctx 51 | attributes: 52 | label: Additional context 53 | description: Anything else you would like to add 54 | validations: 55 | required: false 56 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:allNonMajor", 5 | ":semanticCommits", 6 | ":semanticCommitTypeAll(chore)", 7 | ":gitSignOff" 8 | ], 9 | "dependencyDashboard": false, 10 | "packageRules": [ 11 | { 12 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 13 | "automerge": true 14 | } 15 | ], 16 | "postUpdateOptions": ["gomodTidy", "gomodUpdateImportPaths"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # config options found here: https://github.com/Ezard/semantic-prs 2 | 3 | # Always validate the PR title, and ignore the commits 4 | titleOnly: true 5 | 6 | scopes: 7 | - api 8 | - cli 9 | - ci 10 | - deps 11 | 12 | types: 13 | - feat 14 | - fix 15 | - docs 16 | - refactor 17 | - test 18 | - chore 19 | - revert 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # based on https://github.com/mvdan/github-actions-golang 3 | name: CI 4 | 5 | on: 6 | pull_request: 7 | branches: ["main"] 8 | paths-ignore: ["docs/**"] 9 | 10 | push: 11 | branches: ["main"] 12 | paths-ignore: ["docs/**"] 13 | 14 | # Allows you to run this workflow manually from the Actions tab 15 | workflow_dispatch: 16 | 17 | jobs: 18 | test: 19 | strategy: 20 | matrix: 21 | go-version: [1.23.x] 22 | os: [ubuntu-latest] 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - name: Install Go 27 | uses: actions/setup-go@v4 28 | with: 29 | go-version: ${{ matrix.go-version }} 30 | 31 | - name: Checkout code 32 | uses: actions/checkout@v3 33 | 34 | # cache go modules 35 | - uses: actions/cache@v3 36 | with: 37 | # In order: 38 | # * Module download cache 39 | # * Build cache (Linux) 40 | # * Build cache (Mac) 41 | # * Build cache (Windows) 42 | path: | 43 | ~/go/pkg/mod 44 | ~/.cache/go-build 45 | ~/Library/Caches/go-build 46 | %LocalAppData%\go-build 47 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 48 | restore-keys: | 49 | ${{ runner.os }}-go- 50 | 51 | - name: Downloads the dependencies 52 | run: make download 53 | 54 | - name: Lints all code with golangci-lint 55 | run: make lint 56 | 57 | - name: Runs all tests 58 | run: make test 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | release: 12 | permissions: 13 | id-token: write 14 | packages: write 15 | contents: write 16 | runs-on: ubuntu-latest 17 | env: 18 | REGISTRY: ghcr.io 19 | IMAGE_NAME: ${{ github.repository }} 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Git Fetch 27 | run: git fetch --force --tags 28 | 29 | - name: Setup go 30 | uses: actions/setup-go@v4 31 | with: 32 | go-version: stable 33 | 34 | - name: Cache Go Modules 35 | uses: actions/cache@v4 36 | with: 37 | path: | 38 | ~/.cache/go-build 39 | ~/go/pkg/mod 40 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 41 | restore-keys: | 42 | ${{ runner.os }}-go- 43 | - uses: anchore/sbom-action/download-syft@78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1 # v0.14.3 44 | 45 | - name: Set Up Docker Buildx 46 | uses: docker/setup-buildx-action@v2 47 | 48 | - name: Login to Registry 49 | uses: docker/login-action@v2 50 | with: 51 | registry: ${{ env.REGISTRY }} 52 | username: ${{ github.actor }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Release with Goreleaser 56 | uses: goreleaser/goreleaser-action@v4 57 | with: 58 | distribution: goreleaser 59 | version: latest 60 | args: release --clean 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yaml: -------------------------------------------------------------------------------- 1 | name: Semgrep 2 | 3 | on: 4 | # Scan changed files in PRs, block on new issues only (existing issues ignored) 5 | pull_request: {} 6 | 7 | jobs: 8 | semgrep: 9 | name: Scan 10 | runs-on: ubuntu-latest 11 | # Skip any PR created by dependabot to avoid permission issues 12 | if: (github.actor != 'dependabot[bot]') 13 | steps: 14 | # Fetch project source 15 | - uses: actions/checkout@v3 16 | 17 | - uses: returntocorp/semgrep-action@v1 18 | with: 19 | config: >- # more at semgrep.dev/explore 20 | p/security-audit 21 | p/secrets 22 | p/ci 23 | p/r2c 24 | p/r2c-ci 25 | p/docker 26 | p/dockerfile 27 | p/command-injection 28 | generateSarif: "1" 29 | 30 | # Upload findings to GitHub Advanced Security Dashboard [step 2/2] 31 | - name: Upload SARIF file for GitHub Advanced Security Dashboard 32 | uses: github/codeql-action/upload-sarif@5b6282e01c62d02e720b81eb8a51204f527c3624 # v2.21.3 33 | with: 34 | sarif_file: semgrep.sarif 35 | if: always() 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # jetbrains 24 | .idea/ 25 | 26 | # test 27 | out/ 28 | bin/ 29 | reports/ 30 | dist/ 31 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | # default concurrency is a available CPU number 3 | concurrency: 4 4 | 5 | # timeout for analysis, e.g. 30s, 5m, default is 1m 6 | timeout: 5m 7 | 8 | # exit code when at least one issue was found, default is 1 9 | issues-exit-code: 1 10 | 11 | # include test files or not, default is true 12 | tests: true 13 | 14 | # list of build tags, all linters use it. Default is empty list. 15 | build-tags: 16 | - mytag 17 | 18 | # Allow multiple parallel golangci-lint instances running. 19 | # If false (default) - golangci-lint acquires file lock on start. 20 | allow-parallel-runners: false 21 | 22 | go: "1.23.5" 23 | 24 | 25 | # output configuration options 26 | output: 27 | # print lines of code with issue, default is true 28 | print-issued-lines: true 29 | 30 | # print linter name in the end of issue text, default is true 31 | print-linter-name: true 32 | 33 | # add a prefix to the output file references; default is no prefix 34 | path-prefix: "" 35 | 36 | # sorts results by: filepath, line and column 37 | sort-results: false 38 | 39 | 40 | # all available settings of specific linters 41 | linters-settings: 42 | bidichk: 43 | # The following configurations check for all mentioned invisible unicode 44 | # runes. It can be omitted because all runes are enabled by default. 45 | left-to-right-embedding: true 46 | right-to-left-embedding: true 47 | pop-directional-formatting: true 48 | left-to-right-override: true 49 | right-to-left-override: true 50 | left-to-right-isolate: true 51 | right-to-left-isolate: true 52 | first-strong-isolate: true 53 | pop-directional-isolate: true 54 | 55 | cyclop: 56 | # the maximal code complexity to report 57 | max-complexity: 10 58 | # the maximal average package complexity. If it's higher than 0.0 (float) the check is enabled (default 0.0) 59 | package-average: 0.0 60 | # should ignore tests (default false) 61 | skip-tests: false 62 | 63 | dogsled: 64 | # checks assignments with too many blank identifiers; default is 2 65 | max-blank-identifiers: 2 66 | 67 | dupl: 68 | # tokens count to trigger issue, 150 by default 69 | threshold: 100 70 | 71 | errcheck: 72 | # report about not checking of errors in type assertions: `a := b.(MyStruct)`; 73 | # default is false: such cases aren't reported by default. 74 | check-type-assertions: false 75 | 76 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 77 | # default is false: such cases aren't reported by default. 78 | check-blank: false 79 | 80 | errorlint: 81 | # Check whether fmt.Errorf uses the %w verb for formatting errors. See the readme for caveats 82 | errorf: true 83 | # Check for plain type assertions and type switches 84 | asserts: true 85 | # Check for plain error comparisons 86 | comparison: true 87 | 88 | exhaustive: 89 | # check switch statements in generated files also 90 | check-generated: false 91 | # presence of "default" case in switch statements satisfies exhaustiveness, 92 | # even if all enum members are not listed 93 | default-signifies-exhaustive: false 94 | # enum members matching the supplied regex do not have to be listed in 95 | # switch statements to satisfy exhaustiveness 96 | ignore-enum-members: "" 97 | # consider enums only in package scopes, not in inner scopes 98 | package-scope-only: false 99 | 100 | exhaustivestruct: 101 | # Struct Patterns is list of expressions to match struct packages and names 102 | # The struct packages have the form example.com/package.ExampleStruct 103 | # The matching patterns can use matching syntax from https://pkg.go.dev/path#Match 104 | # If this list is empty, all structs are tested. 105 | struct-patterns: 106 | - '*.Test' 107 | - 'example.com/package.ExampleStruct' 108 | 109 | forbidigo: 110 | # Forbid the following identifiers (identifiers are written using regexp): 111 | forbid: 112 | - ^print.*$ 113 | - 'fmt\.Print.*' 114 | # Exclude godoc examples from forbidigo checks. Default is true. 115 | exclude_godoc_examples: false 116 | 117 | funlen: 118 | lines: 80 119 | statements: 40 120 | 121 | gci: 122 | sections: 123 | - standard 124 | - default 125 | - prefix(github.com/stackitcloud/external-dns-stackit-webhook) 126 | 127 | goimports: 128 | local-prefixes: github.com/stackitcloud/external-dns-stackit-webhook 129 | 130 | gocognit: 131 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 132 | min-complexity: 10 133 | 134 | goconst: 135 | # minimal length of string constant, 3 by default 136 | min-len: 3 137 | # minimum occurrences of constant string count to trigger issue, 3 by default 138 | min-occurrences: 3 139 | # ignore test files, false by default 140 | ignore-tests: false 141 | # look for existing constants matching the values, true by default 142 | match-constant: true 143 | # search also for duplicated numbers, false by default 144 | numbers: false 145 | # minimum value, only works with goconst.numbers, 3 by default 146 | min: 3 147 | # maximum value, only works with goconst.numbers, 3 by default 148 | max: 3 149 | # ignore when constant is not used as function argument, true by default 150 | ignore-calls: true 151 | 152 | # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. 153 | # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". 154 | enabled-tags: 155 | - performance 156 | disabled-tags: 157 | - experimental 158 | 159 | # Settings passed to gocritic. 160 | # The settings key is the name of a supported gocritic checker. 161 | # The list of supported checkers can be find in https://go-critic.github.io/overview. 162 | settings: 163 | captLocal: # must be valid enabled check name 164 | # whether to restrict checker to params only (default true) 165 | paramsOnly: true 166 | elseif: 167 | # whether to skip balanced if-else pairs (default true) 168 | skipBalanced: true 169 | hugeParam: 170 | # size in bytes that makes the warning trigger (default 80) 171 | sizeThreshold: 80 172 | nestingReduce: 173 | # min number of statements inside a branch to trigger a warning (default 5) 174 | bodyWidth: 5 175 | rangeExprCopy: 176 | # size in bytes that makes the warning trigger (default 512) 177 | sizeThreshold: 512 178 | # whether to check test functions (default true) 179 | skipTestFuncs: true 180 | rangeValCopy: 181 | # size in bytes that makes the warning trigger (default 128) 182 | sizeThreshold: 32 183 | # whether to check test functions (default true) 184 | skipTestFuncs: true 185 | ruleguard: 186 | # Enable debug to identify which 'Where' condition was rejected. 187 | # The value of the parameter is the name of a function in a ruleguard file. 188 | # 189 | # When a rule is evaluated: 190 | # If: 191 | # The Match() clause is accepted; and 192 | # One of the conditions in the Where() clause is rejected, 193 | # Then: 194 | # ruleguard prints the specific Where() condition that was rejected. 195 | # 196 | # The flag is passed to the ruleguard 'debug-group' argument. 197 | debug: 'emptyDecl' 198 | # Deprecated, use 'failOn' param. 199 | # If set to true, identical to failOn='all', otherwise failOn='' 200 | failOnError: true 201 | # Determines the behavior when an error occurs while parsing ruleguard files. 202 | # If flag is not set, log error and skip rule files that contain an error. 203 | # If flag is set, the value must be a comma-separated list of error conditions. 204 | # - 'all': fail on all errors. 205 | # - 'import': ruleguard rule imports a package that cannot be found. 206 | # - 'dsl': gorule file does not comply with the ruleguard DSL. 207 | failOn: dsl 208 | tooManyResultsChecker: 209 | maxResults: 10 210 | truncateCmp: 211 | # whether to skip int/uint/uintptr types (default true) 212 | skipArchDependent: true 213 | underef: 214 | # whether to skip (*x).method() calls where x is a pointer receiver (default true) 215 | skipRecvDeref: true 216 | unnamedResult: 217 | # whether to check exported functions 218 | checkExported: true 219 | 220 | gocyclo: 221 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 222 | min-complexity: 10 223 | 224 | godot: 225 | # comments to be checked: `declarations`, `toplevel`, or `all` 226 | scope: declarations 227 | # list of regexps for excluding particular comment lines from check 228 | exclude: 229 | # example: exclude comments which contain numbers 230 | # - '[0-9]+' 231 | # check that each sentence starts with a capital letter 232 | capital: false 233 | 234 | godox: 235 | # report any comments starting with keywords, this is useful for TODO or FIXME comments that 236 | # might be left in the code accidentally and should be resolved before merging 237 | keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting 238 | - NOTE 239 | - OPTIMIZE # marks code that should be optimized before merging 240 | - HACK # marks hack-arounds that should be removed before merging 241 | 242 | gofmt: 243 | # simplify code: gofmt with `-s` option, true by default 244 | simplify: true 245 | 246 | 247 | goheader: 248 | values: 249 | const: 250 | # define here const type values in format k:v, for example: 251 | # COMPANY: MY COMPANY 252 | regexp: 253 | # define here regexp type values, for example 254 | # AUTHOR: .*@mycompany\.com 255 | template: # |- 256 | # put here copyright header template for source code files, for example: 257 | # Note: {{ YEAR }} is a builtin value that returns the year relative to the current machine time. 258 | # 259 | # {{ AUTHOR }} {{ COMPANY }} {{ YEAR }} 260 | # SPDX-License-Identifier: Apache-2.0 261 | 262 | # Licensed under the Apache License, Version 2.0 (the "License"); 263 | # you may not use this file except in compliance with the License. 264 | # You may obtain a copy of the License at: 265 | 266 | # http://www.apache.org/licenses/LICENSE-2.0 267 | 268 | # Unless required by applicable law or agreed to in writing, software 269 | # distributed under the License is distributed on an "AS IS" BASIS, 270 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 271 | # See the License for the specific language governing permissions and 272 | # limitations under the License. 273 | template-path: 274 | # also as alternative of directive 'template' you may put the path to file with the template source 275 | 276 | golint: 277 | # minimal confidence for issues, default is 0.8 278 | min-confidence: 0.8 279 | 280 | gomnd: 281 | settings: 282 | mnd: 283 | # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. 284 | checks: argument,case,condition,operation,return,assign 285 | # ignored-numbers: 1000 286 | # ignored-files: magic_.*.go 287 | # ignored-functions: math.* 288 | 289 | gomoddirectives: 290 | # Allow local `replace` directives. Default is false. 291 | replace-local: false 292 | # List of allowed `replace` directives. Default is empty. 293 | replace-allow-list: 294 | - launchpad.net/gocheck 295 | # Allow to not explain why the version has been retracted in the `retract` directives. Default is false. 296 | retract-allow-no-explanation: false 297 | # Forbid the use of the `exclude` directives. Default is false. 298 | exclude-forbidden: false 299 | 300 | gomodguard: 301 | allowed: 302 | modules: # List of allowed modules 303 | # - gopkg.in/yaml.v2 304 | domains: # List of allowed module domains 305 | # - golang.org 306 | blocked: 307 | modules: # List of blocked modules 308 | # - github.com/uudashr/go-module: # Blocked module 309 | # recommendations: # Recommended modules that should be used instead (Optional) 310 | # - golang.org/x/mod 311 | # reason: "`mod` is the official go.mod parser library." # Reason why the recommended module should be used (Optional) 312 | versions: # List of blocked module version constraints 313 | # - github.com/mitchellh/go-homedir: # Blocked module with version constraint 314 | # version: "< 1.1.0" # Version constraint, see https://github.com/Masterminds/semver#basic-comparisons 315 | # reason: "testing if blocked version constraint works." # Reason why the version constraint exists. (Optional) 316 | local_replace_directives: false # Set to true to raise lint issues for packages that are loaded from a local path via replace directive 317 | 318 | gosec: 319 | # To select a subset of rules to run. 320 | # Available rules: https://github.com/securego/gosec#available-rules 321 | includes: 322 | - G401 323 | - G306 324 | - G101 325 | # To specify a set of rules to explicitly exclude. 326 | # Available rules: https://github.com/securego/gosec#available-rules 327 | excludes: 328 | - G204 329 | # Exclude generated files 330 | exclude-generated: true 331 | # Filter out the issues with a lower severity than the given value. Valid options are: low, medium, high. 332 | severity: "low" 333 | # Filter out the issues with a lower confidence than the given value. Valid options are: low, medium, high. 334 | confidence: "low" 335 | # To specify the configuration of rules. 336 | # The configuration of rules is not fully documented by gosec: 337 | # https://github.com/securego/gosec#configuration 338 | # https://github.com/securego/gosec/blob/569328eade2ccbad4ce2d0f21ee158ab5356a5cf/rules/rulelist.go#L60-L102 339 | config: 340 | G306: "0600" 341 | G101: 342 | pattern: "(?i)example" 343 | ignore_entropy: false 344 | entropy_threshold: "80.0" 345 | per_char_threshold: "3.0" 346 | truncate: "32" 347 | 348 | gosimple: 349 | # https://staticcheck.io/docs/options#checks 350 | checks: [ "all" ] 351 | 352 | govet: 353 | # settings per analyzer 354 | settings: 355 | printf: # analyzer name, run `go tool vet help` to see all analyzers 356 | funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer 357 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 358 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 359 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 360 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 361 | 362 | # enable or disable analyzers by name 363 | # run `go tool vet help` to see all analyzers 364 | enable: 365 | - atomicalign 366 | - shadow 367 | 368 | enable-all: false 369 | disable-all: false 370 | 371 | depguard: 372 | list-type: blacklist 373 | include-go-root: false 374 | packages: 375 | - github.com/sirupsen/logrus 376 | packages-with-error-message: 377 | # specify an error message to output when a blacklisted package is used 378 | - github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" 379 | 380 | ifshort: 381 | # Maximum length of variable declaration measured in number of lines, after which linter won't suggest using short syntax. 382 | # Has higher priority than max-decl-chars. 383 | max-decl-lines: 1 384 | # Maximum length of variable declaration measured in number of characters, after which linter won't suggest using short syntax. 385 | max-decl-chars: 30 386 | 387 | importas: 388 | # if set to `true`, force to use alias. 389 | no-unaliased: true 390 | # List of aliases 391 | alias: 392 | # using `servingv1` alias for `knative.dev/serving/pkg/apis/serving/v1` package 393 | - pkg: knative.dev/serving/pkg/apis/serving/v1 394 | alias: servingv1 395 | # using `autoscalingv1alpha1` alias for `knative.dev/serving/pkg/apis/autoscaling/v1alpha1` package 396 | - pkg: knative.dev/serving/pkg/apis/autoscaling/v1alpha1 397 | alias: autoscalingv1alpha1 398 | # You can specify the package path by regular expression, 399 | # and alias by regular expression expansion syntax like below. 400 | # see https://github.com/julz/importas#use-regular-expression for details 401 | - pkg: knative.dev/serving/pkg/apis/(\w+)/(v[\w\d]+) 402 | alias: $1$2 403 | 404 | lll: 405 | # max line length, lines longer will be reported. Default is 120. 406 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 407 | line-length: 120 408 | # tab width in spaces. Default to 1. 409 | tab-width: 1 410 | 411 | makezero: 412 | # Allow only slices initialized with a length of zero. Default is false. 413 | always: false 414 | 415 | maligned: 416 | # print struct with more effective memory layout or not, false by default 417 | suggest-new: true 418 | 419 | misspell: 420 | # Correct spellings using locale preferences for US or UK. 421 | # Default is to use a neutral variety of English. 422 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 423 | locale: US 424 | ignore-words: 425 | - someword 426 | 427 | nakedret: 428 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 429 | max-func-lines: 30 430 | 431 | nestif: 432 | # minimal complexity of if statements to report, 5 by default 433 | min-complexity: 4 434 | 435 | nilnil: 436 | # By default, nilnil checks all returned types below. 437 | checked-types: 438 | - ptr 439 | - func 440 | - iface 441 | - map 442 | - chan 443 | 444 | nlreturn: 445 | # size of the block (including return statement that is still "OK") 446 | # so no return split required. 447 | block-size: 1 448 | 449 | nolintlint: 450 | # Disable to ensure that all nolint directives actually have an effect. Default is true. 451 | allow-unused: false 452 | # Disable to ensure that nolint directives don't have a leading space. Default is true. 453 | allow-leading-space: true 454 | # Exclude following linters from requiring an explanation. Default is []. 455 | allow-no-explanation: [ ] 456 | # Enable to require an explanation of nonzero length after each nolint directive. Default is false. 457 | require-explanation: true 458 | # Enable to require nolint directives to mention the specific linter being suppressed. Default is false. 459 | require-specific: true 460 | 461 | prealloc: 462 | # XXX: we don't recommend using this linter before doing performance profiling. 463 | # For most programs usage of prealloc will be a premature optimization. 464 | 465 | # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. 466 | # True by default. 467 | simple: true 468 | range-loops: true # Report preallocation suggestions on range loops, true by default 469 | for-loops: false # Report preallocation suggestions on for loops, false by default 470 | 471 | promlinter: 472 | # Promlinter cannot infer all metrics_registry name in static analysis. 473 | # Enable strict mode will also include the errors caused by failing to parse the args. 474 | strict: false 475 | # Please refer to https://github.com/yeya24/promlinter#usage for detailed usage. 476 | disabled-linters: 477 | # - "Help" 478 | # - "MetricUnits" 479 | # - "Counter" 480 | # - "HistogramSummaryReserved" 481 | # - "MetricTypeInName" 482 | # - "ReservedChars" 483 | # - "CamelCase" 484 | # - "lintUnitAbbreviations" 485 | 486 | predeclared: 487 | # comma-separated list of predeclared identifiers to not report on 488 | ignore: "" 489 | # include method names and field names (i.e., qualified names) in checks 490 | q: false 491 | 492 | rowserrcheck: 493 | packages: 494 | - github.com/jmoiron/sqlx 495 | 496 | revive: 497 | # see https://github.com/mgechev/revive#available-rules for details. 498 | ignore-generated-header: true 499 | severity: warning 500 | rules: 501 | - name: indent-error-flow 502 | severity: warning 503 | - name: add-constant 504 | severity: warning 505 | arguments: 506 | - maxLitCount: "3" 507 | allowStrs: '""' 508 | allowInts: "0,1,2" 509 | allowFloats: "0.0,0.,1.0,1.,2.0,2." 510 | 511 | staticcheck: 512 | # https://staticcheck.io/docs/options#checks 513 | checks: [ "all" ] 514 | 515 | stylecheck: 516 | # https://staticcheck.io/docs/options#checks 517 | checks: [ "all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022" ] 518 | # https://staticcheck.io/docs/options#dot_import_whitelist 519 | dot-import-whitelist: 520 | - fmt 521 | # https://staticcheck.io/docs/options#initialisms 522 | initialisms: [ "ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS" ] 523 | # https://staticcheck.io/docs/options#http_status_code_whitelist 524 | http-status-code-whitelist: [ "200", "400", "404", "500" ] 525 | 526 | tagliatelle: 527 | # check the struck tag name case 528 | case: 529 | # use the struct field name to check the name of the struct tag 530 | use-field-name: true 531 | rules: 532 | # any struct tag type can be used. 533 | # support string case: `camel`, `pascal`, `kebab`, `snake`, `goCamel`, `goPascal`, `goKebab`, `goSnake`, `upper`, `lower` 534 | json: camel 535 | yaml: camel 536 | xml: camel 537 | bson: camel 538 | avro: snake 539 | mapstructure: kebab 540 | 541 | thelper: 542 | # The following configurations enable all checks. It can be omitted because all checks are enabled by default. 543 | # You can enable only required checks deleting unnecessary checks. 544 | test: 545 | first: true 546 | name: true 547 | begin: true 548 | benchmark: 549 | first: true 550 | name: true 551 | begin: true 552 | tb: 553 | first: true 554 | name: true 555 | begin: true 556 | 557 | unparam: 558 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 559 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 560 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 561 | # with golangci-lint call it on a directory with the changed file. 562 | check-exported: false 563 | 564 | unused: 565 | 566 | varnamelen: 567 | # The longest distance, in source lines, that is being considered a "small scope." (defaults to 5) 568 | # Variables used in at most this many lines will be ignored. 569 | max-distance: 5 570 | # The minimum length of a variable's name that is considered "long." (defaults to 3) 571 | # Variable names that are at least this long will be ignored. 572 | min-name-length: 3 573 | # Check method receiver names. (defaults to false) 574 | check-receiver: false 575 | # Check named return values. (defaults to false) 576 | check-return: false 577 | # Ignore "ok" variables that hold the bool return value of a type assertion. (defaults to false) 578 | ignore-type-assert-ok: false 579 | # Ignore "ok" variables that hold the bool return value of a map index. (defaults to false) 580 | ignore-map-index-ok: false 581 | # Ignore "ok" variables that hold the bool return value of a channel receive. (defaults to false) 582 | ignore-chan-recv-ok: false 583 | # Optional list of variable names that should be ignored completely. (defaults to empty list) 584 | ignore-names: 585 | - err 586 | # Optional list of variable declarations that should be ignored completely. (defaults to empty list) 587 | # Entries must be in the form of " " or " *". 588 | ignore-decls: 589 | - t testing.T 590 | - ch chan<- prometheus.Metric 591 | 592 | whitespace: 593 | multi-if: false # Enforces newlines (or comments) after every multi-line if statement 594 | multi-func: false # Enforces newlines (or comments) after every multi-line function signature 595 | 596 | wsl: 597 | # See https://github.com/bombsimon/wsl/blob/master/doc/configuration.md for 598 | # documentation of available settings. These are the defaults for 599 | # `golangci-lint`. 600 | allow-assign-and-anything: false 601 | allow-assign-and-call: true 602 | allow-cuddle-declarations: false 603 | allow-multiline-assign: true 604 | allow-separated-leading-comment: false 605 | allow-trailing-comment: false 606 | force-case-trailing-whitespace: 0 607 | force-err-cuddling: false 608 | force-short-decl-cuddling: false 609 | strict-append: true 610 | 611 | linters: 612 | enable: 613 | - asciicheck 614 | - bidichk 615 | - contextcheck 616 | - cyclop 617 | - dogsled 618 | - dupl 619 | - durationcheck 620 | - errcheck 621 | - errname 622 | - errorlint 623 | - exhaustive 624 | - forcetypeassert 625 | - funlen 626 | - gci 627 | - gocognit 628 | - goconst 629 | - gocritic 630 | - gocyclo 631 | - godot 632 | - godox 633 | - gofmt 634 | - goheader 635 | - gomoddirectives 636 | - gomodguard 637 | - goprintffuncname 638 | - gosec 639 | - gosimple 640 | - govet 641 | - importas 642 | - ineffassign 643 | - makezero 644 | - misspell 645 | - nakedret 646 | - nestif 647 | - nilerr 648 | - nilnil 649 | - nlreturn 650 | - noctx 651 | - nolintlint 652 | - prealloc 653 | - predeclared 654 | - promlinter 655 | - staticcheck 656 | - thelper 657 | - tparallel 658 | - unconvert 659 | - unparam 660 | - unused 661 | - usetesting 662 | - whitespace 663 | - paralleltest 664 | enable-all: false 665 | disable: 666 | - depguard 667 | - gochecknoinits 668 | - forbidigo 669 | - bodyclose 670 | - ireturn 671 | - goimports 672 | - lll 673 | - stylecheck 674 | - testpackage 675 | - wrapcheck 676 | - typecheck 677 | - tagliatelle 678 | - err113 679 | - gochecknoglobals 680 | - wsl 681 | - revive 682 | - varnamelen 683 | fast: false 684 | 685 | 686 | issues: 687 | # List of regexps of issue texts to exclude, empty list by default. 688 | # But independently from this option we use default exclude patterns, 689 | # it can be disabled by `exclude-use-default: false`. To list all 690 | # excluded by default patterns execute `golangci-lint run --help` 691 | exclude: 692 | - abcdef 693 | 694 | # Excluding configuration per-path, per-linter, per-text and per-source 695 | exclude-rules: 696 | # Exclude some linters from running on tests files. 697 | - path: _test\.go 698 | linters: 699 | - gocyclo 700 | - errcheck 701 | - dupl 702 | - gosec 703 | 704 | - path: include/ # for excluding directories, e.g., vendor directory 705 | linters: 706 | - nlreturn 707 | - unparam 708 | - gocognit 709 | - errcheck 710 | 711 | - path: internal/adapters/db 712 | linters: 713 | - typecheck 714 | 715 | - path: cmd 716 | linters: 717 | - typecheck 718 | 719 | # Exclude known linters from partially hard-vendored code, 720 | # which is impossible to exclude via "nolint" comments. 721 | - path: settings/hmac/ 722 | text: "weak cryptographic primitive" 723 | linters: 724 | - gosec 725 | 726 | # Exclude some staticcheck messages 727 | - linters: 728 | - staticcheck 729 | text: "SA9003:" 730 | 731 | # Exclude lll issues for long lines with go:generate 732 | - linters: 733 | - lll 734 | source: "^//go:generate " 735 | 736 | # Independently from option `exclude` we use default exclude patterns, 737 | # it can be disabled by this option. To list all 738 | # excluded by default patterns execute `golangci-lint run --help`. 739 | # Default value for this option is true. 740 | exclude-use-default: false 741 | 742 | # The default value is false. If set to true exclude and exclude-rules 743 | # regular expressions become case sensitive. 744 | exclude-case-sensitive: false 745 | 746 | # The list of ids of default excludes to include or disable. By default it's empty. 747 | include: 748 | - EXC0002 # disable excluding of issues about comments from golint 749 | 750 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 751 | max-issues-per-linter: 0 752 | 753 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 754 | max-same-issues: 0 755 | 756 | # Show only new issues: if there are unstaged changes or untracked files, 757 | # only those changes are analyzed, else only changes in HEAD~ are analyzed. 758 | # It's a super-useful option for integration of golangci-lint into existing 759 | # large codebase. It's not practical to fix all existing issues at the moment 760 | # of integration: much better don't allow issues in new code. 761 | # Default is false. 762 | new: false 763 | 764 | # Fix found issues (if it's supported by the linter) 765 | fix: true 766 | 767 | 768 | severity: 769 | # Default value is empty string. 770 | # Set the default severity for issues. If severity rules are defined and the issues 771 | # do not match or no severity is provided to the rule this will be the default 772 | # severity applied. Severities should match the supported severity names of the 773 | # selected out format. 774 | # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity 775 | # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity 776 | # - GitHub: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message 777 | default-severity: error 778 | 779 | # The default value is false. 780 | # If set to true severity-rules regular expressions become case sensitive. 781 | case-sensitive: false 782 | 783 | # Default value is empty list. 784 | # When a list of severity rules are provided, severity information will be added to lint 785 | # issues. Severity rules have the same filtering capability as exclude rules except you 786 | # are allowed to specify one matcher per severity rule. 787 | # Only affects out formats that support setting severity information. 788 | rules: 789 | - linters: 790 | - dupl 791 | severity: info 792 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: external-dns-stackit-webhook 4 | snapshot: 5 | version_template: '{{ .Tag }}-SNAPSHOT' 6 | 7 | builds: 8 | - id: external-dns-stackit-webhook 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | main: ./cmd/webhook 17 | binary: external-dns-stackit-webhook 18 | env: 19 | - CGO_ENABLED=0 20 | ldflags: 21 | - -s 22 | - -w 23 | - -X 'main.Version={{.Version}}' 24 | - -X 'main.Gitsha={{.ShortCommit}}' 25 | source: 26 | enabled: true 27 | archives: 28 | - format: tar.gz 29 | # this name template makes the OS and Arch compatible with the results of uname. 30 | name_template: >- 31 | {{ .ProjectName }}_ 32 | {{- title .Os }}_ 33 | {{- if eq .Arch "amd64" }}x86_64 34 | {{- else if eq .Arch "386" }}i386 35 | {{- else }}{{ .Arch }}{{ end }} 36 | {{- if .Arm }}v{{ .Arm }}{{ end }} 37 | # use zip for windows archives 38 | format_overrides: 39 | - goos: windows 40 | format: zip 41 | dockers: 42 | - id: external-dns-stackit-webhook-amd64 43 | use: buildx 44 | image_templates: 45 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}-amd64" 46 | goos: linux 47 | goarch: amd64 48 | build_flag_templates: 49 | - --label=org.opencontainers.image.title={{ .ProjectName }} 50 | - --label=org.opencontainers.image.description=stackit DNS webhook for external-dns 51 | - --label=org.opencontainers.image.url=https://{{ .Env.GITHUB_SERVER_URL }}/{{ .Env.GITHUB_REPOSITORY}} 52 | - --label=org.opencontainers.image.source=https://{{ .Env.GITHUB_SERVER_URL }}/{{ .Env.GITHUB_REPOSITORY}} 53 | - --label=org.opencontainers.image.version={{ .Version }} 54 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 55 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 56 | - --label=org.opencontainers.image.licenses=Apache-2.0 57 | - --platform=linux/amd64 58 | skip_push: false 59 | 60 | - id: external-dns-stackit-webhook-arm64 61 | use: buildx 62 | image_templates: 63 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}-arm64" 64 | goos: linux 65 | goarch: arm64 66 | build_flag_templates: 67 | - --label=org.opencontainers.image.title={{ .ProjectName }} 68 | - --label=org.opencontainers.image.description=stackit DNS webhook for external-dns 69 | - --label=org.opencontainers.image.url=https://{{ .Env.GITHUB_SERVER_URL }}/{{ .Env.GITHUB_REPOSITORY}} 70 | - --label=org.opencontainers.image.source=https://{{ .Env.GITHUB_SERVER_URL }}/{{ .Env.GITHUB_REPOSITORY}} 71 | - --label=org.opencontainers.image.version={{ .Version }} 72 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 73 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 74 | - --label=org.opencontainers.image.licenses=Apache-2.0 75 | - --label=org.opencontainers.image.platform.os=linux 76 | - --platform=linux/arm64 77 | skip_push: false 78 | 79 | # https://goreleaser.com/cookbooks/multi-platform-docker-images 80 | docker_manifests: 81 | - name_template: "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}" 82 | image_templates: 83 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}-amd64" 84 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}-arm64" 85 | - name_template: "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:latest" 86 | image_templates: 87 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}-amd64" 88 | - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}-arm64" 89 | 90 | checksum: 91 | disable: false 92 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 93 | dist: dist 94 | changelog: 95 | use: github 96 | sort: asc 97 | filters: 98 | exclude: 99 | - '^docs:' 100 | - '^test:' 101 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * patrick.koss@mail.schwarz 2 | * marius.galm@mail.schwarz 3 | * simon.stier@mail.schwarz 4 | * christopher.paul@mail.schwarz 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to External DNS STACKIT Webhook 2 | 3 | Welcome and thank you for making it this far and considering contributing to external-dns-stackit-webhook. 4 | We always appreciate any contributions by raising issues, improving the documentation, fixing bugs in the CLI or adding new features. 5 | 6 | Before opening a PR please read through this document. 7 | If you want to contribute but don't know how to start or have any questions feel free to reach out to us on [Github Discussions](https://github.com/stackitcloud/external-dns-stackit-webhook/discussions). Answering any questions or discussions there is also a great way to contribute to the community. 8 | 9 | ## Process of making an addition 10 | 11 | > Please keep in mind to open an issue whenever you plan to make an addition to features to discuss it before implementing it. 12 | 13 | To contribute any code to this repository just do the following: 14 | 15 | 1. Make sure you have Go's latest version installed 16 | 2. Fork this repository 17 | 3. Run `make build` to make sure everything's setup correctly 18 | 4. Make your changes 19 | > Please follow the [seven rules of greate Git commit messages](https://chris.beams.io/posts/git-commit/#seven-rules) 20 | > and make sure to keep your commits clean and atomic. 21 | > Your PR won't be squashed before merging so the commits should tell a story. 22 | > 23 | > Optional: Sign-off on all Git commits by running `git commit -s`. 24 | > Take a look at the [Gihub Docs](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) for further information. 25 | > 26 | > Add documentation and tests for your addition if needed. 27 | 5. Run `make lint test` to ensure your code is ready to be merged 28 | > If any linting issues occur please fix them. 29 | > Using a nolint directive should only be used as a last resort. 30 | 6. Open a PR and make sure the CI pipelines succeed. 31 | > Your PR needs to have a semantic title, which can look like: `type(scope) Short Description` 32 | > All available `scopes` & `types` are defined in [semantic.yml](https://github.com/stackitcloud/external-dns-stackit-webhook/blob/main/.github/semantic.yml) 33 | > 34 | > A example PR tile for adding a new feature for the CLI would looks like: `cli(feat) Add saving output to file` 35 | 7. Wait for one of the maintainers to review your code and react to the comments. 36 | 8. After approval merge the PR 37 | 9. Thank you for your contribution! :) 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static-debian11:nonroot 2 | 3 | COPY external-dns-stackit-webhook /external-dns-stackit-webhook 4 | 5 | ENTRYPOINT ["/external-dns-stackit-webhook"] 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOLANGCI_VERSION = 1.64.8 2 | LICENCES_IGNORE_LIST = $(shell cat licenses/licenses-ignore-list.txt) 3 | 4 | VERSION ?= 0.0.1 5 | IMAGE_TAG_BASE ?= stackitcloud/external-dns-stackit-webhook 6 | IMG ?= $(IMAGE_TAG_BASE):$(VERSION) 7 | 8 | BUILD_VERSION ?= $(shell git branch --show-current) 9 | BUILD_COMMIT ?= $(shell git rev-parse --short HEAD) 10 | BUILD_TIMESTAMP ?= $(shell date -u '+%Y-%m-%d %H:%M:%S') 11 | 12 | PWD = $(shell pwd) 13 | export PATH := $(PWD)/bin:$(PATH) 14 | 15 | download: 16 | go mod download 17 | 18 | .PHONY: build 19 | build: 20 | CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/external-dns-stackit-webhook -v cmd/webhook/main.go 21 | 22 | .PHONY: docker-build 23 | docker-build: 24 | docker build -t $(IMG) -f Dockerfile . 25 | 26 | test: 27 | go test -race ./... 28 | 29 | mocks: 30 | go generate ./... 31 | 32 | GOLANGCI_LINT = bin/golangci-lint-$(GOLANGCI_VERSION) 33 | $(GOLANGCI_LINT): 34 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | bash -s -- -b bin v$(GOLANGCI_VERSION) 35 | @mv bin/golangci-lint "$(@)" 36 | 37 | lint: $(GOLANGCI_LINT) download 38 | $(GOLANGCI_LINT) run -v 39 | 40 | out: 41 | @mkdir -pv "$(@)" 42 | 43 | reports: 44 | @mkdir -pv "$(@)/licenses" 45 | 46 | coverage: out 47 | go test -race ./... -coverprofile=out/cover.out 48 | 49 | html-coverage: out/report.json 50 | go tool cover -html=out/cover.out 51 | 52 | .PHONY: out/report.json 53 | out/report.json: 54 | go test -race ./... -coverprofile=out/cover.out --json | tee "$(@)" 55 | 56 | run: 57 | go run cmd/webhook/main.go 58 | 59 | .PHONY: clean 60 | clean: 61 | rm -rf ./bin 62 | rm -rf ./out 63 | 64 | GO_RELEASER = bin/goreleaser 65 | $(GO_RELEASER): 66 | GOBIN=$(PWD)/bin go install github.com/goreleaser/goreleaser@latest 67 | 68 | .PHONY: release-check 69 | release-check: $(GO_RELEASER) ## Check if the release will work 70 | GITHUB_SERVER_URL=github.com GITHUB_REPOSITORY=stackitcloud/external-dns-stackit-webhook REGISTRY=$(REGISTRY) IMAGE_NAME=$(IMAGE_NAME) $(GO_RELEASER) release --snapshot --clean --skip-publish 71 | 72 | GO_LICENSES = bin/go-licenses 73 | $(GO_LICENSES): 74 | GOBIN=$(PWD)/bin go install github.com/google/go-licenses 75 | 76 | .PHONY: license-check 77 | license-check: $(GO_LICENSES) reports ## Check licenses against code. 78 | $(GO_LICENSES) check --include_tests --ignore $(LICENCES_IGNORE_LIST) ./... 79 | 80 | .PHONY: license-report 81 | license-report: $(GO_LICENSES) reports ## Create licenses report against code. 82 | $(GO_LICENSES) report --include_tests --ignore $(LICENCES_IGNORE_LIST) ./... > ./reports/licenses/licenses-list.csv 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # STACKIT Webhook - ExternalDNS 2 | 3 | [![GoTemplate](https://img.shields.io/badge/go/template-black?logo=go)](https://github.com/golang-standards/project-layout) 4 | [![CI](https://github.com/stackitcloud/external-dns-stackit-webhook/actions/workflows/main.yml/badge.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/actions/workflows/main.yml) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/stackitcloud/external-dns-stackit-webhook)](https://goreportcard.com/report/github.com/stackitcloud/external-dns-stackit-webhook) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![GitHub release](https://img.shields.io/github/release/stackitcloud/external-dns-stackit-webhook.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/releases) 8 | [![Last Commit](https://img.shields.io/github/last-commit/stackitcloud/external-dns-stackit-webhook/main.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/commits/main) 9 | [![GitHub issues](https://img.shields.io/github/issues/stackitcloud/external-dns-stackit-webhook.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/issues) 10 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/stackitcloud/external-dns-stackit-webhook.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/pulls) 11 | [![GitHub stars](https://img.shields.io/github/stars/stackitcloud/external-dns-stackit-webhook.svg?style=social&label=Star&maxAge=2592000)](https://github.com/stackitcloud/external-dns-stackit-webhook/stargazers) 12 | [![GitHub forks](https://img.shields.io/github/forks/stackitcloud/external-dns-stackit-webhook.svg?style=social&label=Fork&maxAge=2592000)](https://github.com/stackitcloud/external-dns-stackit-webhook/network) 13 | 14 | ExternalDNS serves as an add-on for Kubernetes designed to automate the management of Domain Name System (DNS) 15 | records for Kubernetes services by utilizing various DNS providers. While Kubernetes traditionally manages DNS 16 | records internally, ExternalDNS augments this functionality by transferring the responsibility of DNS records 17 | management to an external DNS provider such as STACKIT. Consequently, the STACKIT webhook enables the management 18 | of your STACKIT domains within your Kubernetes cluster using 19 | [ExternalDNS](https://github.com/kubernetes-sigs/external-dns). 20 | 21 | For utilizing ExternalDNS with STACKIT, it is mandatory to establish a STACKIT project, a service account 22 | within the project, generate an authentication token for the service account, authorize the service account 23 | to create and read dns zones, and finally, establish a STACKIT zone. 24 | 25 | ## Kubernetes Deployment 26 | 27 | The STACKIT webhook is presented as a standard Open Container Initiative (OCI) image released in the 28 | [GitHub container registry](https://github.com/stackitcloud/external-dns-stackit-webhook/pkgs/container/external-dns-stackit-webhook). 29 | The deployment is compatible with all Kubernetes-supported methods. The subsequent example 30 | demonstrates the deployment as a 31 | [sidecar container](https://kubernetes.io/docs/concepts/workloads/pods/#workload-resources-for-managing-pods) 32 | within the ExternalDNS pod. 33 | 34 | ```shell 35 | # We create a Secret from an auth token. Alternatively, you can also 36 | # use keys to authenticate the webhook - see "Authentication" below. 37 | kubectl create secret generic external-dns-stackit-webhook --from-literal=auth-token='' 38 | ``` 39 | 40 | ```shell 41 | kubectl apply -f - <Why isn't it working? 277 | 278 | Answer: The External DNS will try to create a TXT record named `a-example.runs.onstackit.cloud`, which will fail 279 | because you can't establish a record outside the zone. The solution is to use a name that's within the zone, such as 280 | `nginx.example.runs.onstackit.cloud`. 281 | 282 | ### 2. Issues with Creating Ingresses not in the Zone 283 | 284 | For a project containing the zone `example.runs.onstackit.cloud`, suppose you've created these two ingress: 285 | 286 | ```yaml 287 | apiVersion: networking.k8s.io/v1 288 | kind: Ingress 289 | metadata: 290 | annotations: 291 | ingress.kubernetes.io/rewrite-target: / 292 | kubernetes.io/ingress.class: nginx 293 | name: example-ingress-external-dns 294 | namespace: default 295 | spec: 296 | rules: 297 | - host: test.example.runs.onstackit.cloud 298 | http: 299 | paths: 300 | - backend: 301 | service: 302 | name: example 303 | port: 304 | number: 80 305 | path: / 306 | pathType: Prefix 307 | - host: test.example.stackit.rocks 308 | http: 309 | paths: 310 | - backend: 311 | service: 312 | name: example 313 | port: 314 | number: 80 315 | path: / 316 | pathType: Prefix 317 | ``` 318 | 319 | Why isn't it working? 320 | 321 | Answer: External DNS will attempt to establish a record set for `test.example.stackit.rocks`. As the zone 322 | `example.stackit.rocks` isn't within the project, it'll fail. There are two potential fixes: 323 | 324 | - Incorporate the zone `example.stackit.rocks` into the project. 325 | - Adjust the domain filter to `example.runs.onstackit.cloud` by setting the domain filter 326 | flag `--domain-filter="example.runs.onstackit.cloud"`. This will exclude `test.example.stackit.rocks` and only 327 | generate 328 | the record set for `test.example.runs.onstackit.cloud`. 329 | 330 | ## Development 331 | 332 | Run the app: 333 | 334 | ```bash 335 | export BASE_URL="https://dns.api.stackit.cloud" 336 | export PROJECT_ID="c158c736-0300-4044-95c4-b7d404279b35" 337 | export AUTH_TOKEN="your-auth-token" 338 | 339 | make run 340 | ``` 341 | 342 | Lint the code: 343 | 344 | ```bash 345 | make lint 346 | ``` 347 | 348 | Test the code: 349 | 350 | ```bash 351 | make test 352 | ``` 353 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | **Please do not report security vulnerabilities through public GitHub issues.** 4 | 5 | We at STACKIT take security seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. 6 | 7 | To report a security issue, please send an email to [stackit-security@stackit.de](mailto:stackit-security@stackit.de). 8 | 9 | Our team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 10 | -------------------------------------------------------------------------------- /cmd/webhook/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | "github.com/spf13/viper" 11 | "go.uber.org/zap" 12 | "go.uber.org/zap/zapcore" 13 | "sigs.k8s.io/external-dns/endpoint" 14 | 15 | "github.com/stackitcloud/external-dns-stackit-webhook/internal/stackitprovider" 16 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 17 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" 18 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/stackit" 19 | ) 20 | 21 | var ( 22 | apiPort string 23 | authBearerToken string 24 | authKeyPath string 25 | baseUrl string 26 | projectID string 27 | worker int 28 | domainFilter []string 29 | dryRun bool 30 | logLevel string 31 | ) 32 | 33 | var rootCmd = &cobra.Command{ 34 | Use: "external-dns-stackit-webhook", 35 | Short: "provider webhook for the STACKIT DNS service", 36 | Long: "provider webhook for the STACKIT DNS service", 37 | Run: func(cmd *cobra.Command, args []string) { 38 | logger := getLogger() 39 | defer func(logger *zap.Logger) { 40 | err := logger.Sync() 41 | if err != nil { 42 | log.Println(err) 43 | } 44 | }(logger) 45 | 46 | endpointDomainFilter := endpoint.DomainFilter{Filters: domainFilter} 47 | 48 | stackitConfigOptions, err := stackit.SetConfigOptions(baseUrl, authBearerToken, authKeyPath) 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | stackitProvider, err := stackitprovider.NewStackitDNSProvider( 54 | logger.With(zap.String("component", "stackitprovider")), 55 | // ExternalDNS provider config 56 | stackitprovider.Config{ 57 | ProjectId: projectID, 58 | DomainFilter: endpointDomainFilter, 59 | DryRun: dryRun, 60 | Workers: worker, 61 | }, 62 | // STACKIT client SDK config 63 | stackitConfigOptions..., 64 | ) 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | app := api.New(logger.With(zap.String("component", "api")), metrics.NewHttpApiMetrics(), stackitProvider) 70 | err = app.Listen(apiPort) 71 | if err != nil { 72 | panic(err) 73 | } 74 | }, 75 | } 76 | 77 | func getLogger() *zap.Logger { 78 | cfg := zap.Config{ 79 | Level: zap.NewAtomicLevelAt(getZapLogLevel()), 80 | Encoding: "json", // or "console" 81 | // ... other zap configuration as needed 82 | OutputPaths: []string{"stdout"}, 83 | ErrorOutputPaths: []string{"stderr"}, 84 | } 85 | 86 | logger, errLogger := cfg.Build() 87 | if errLogger != nil { 88 | panic(errLogger) 89 | } 90 | 91 | return logger 92 | } 93 | 94 | func getZapLogLevel() zapcore.Level { 95 | switch logLevel { 96 | case "debug": 97 | return zapcore.DebugLevel 98 | case "info": 99 | return zapcore.InfoLevel 100 | case "warn": 101 | return zapcore.WarnLevel 102 | case "error": 103 | return zapcore.ErrorLevel 104 | default: 105 | return zapcore.InfoLevel 106 | } 107 | } 108 | 109 | func Execute() error { 110 | return rootCmd.Execute() 111 | } 112 | 113 | func init() { 114 | cobra.OnInitialize(initConfig) 115 | 116 | rootCmd.PersistentFlags().StringVar(&apiPort, "api-port", "8888", "Specifies the port to listen on.") 117 | rootCmd.PersistentFlags().StringVar(&authBearerToken, "auth-token", "", "Defines the authentication token for the STACKIT API. Mutually exclusive with 'auth-key-path'.") 118 | rootCmd.PersistentFlags().StringVar(&authKeyPath, "auth-key-path", "", "Defines the file path of the service account key for the STACKIT API. Mutually exclusive with 'auth-token'.") 119 | rootCmd.PersistentFlags().StringVar(&baseUrl, "base-url", "https://dns.api.stackit.cloud", " Identifies the Base URL for utilizing the API.") 120 | rootCmd.PersistentFlags().StringVar(&projectID, "project-id", "", "Specifies the project id of the STACKIT project.") 121 | rootCmd.PersistentFlags().IntVar(&worker, "worker", 10, "Specifies the number "+ 122 | "of workers to employ for querying the API. Given that we need to iterate over all zones and "+ 123 | "records, it can be parallelized. However, it is important to avoid setting this number "+ 124 | "excessively high to prevent receiving 429 rate limiting from the API.") 125 | rootCmd.PersistentFlags().StringArrayVar(&domainFilter, "domain-filter", []string{}, "Establishes a filter for DNS zone names") 126 | rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Specifies whether to perform a dry run.") 127 | rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Specifies the log level. Possible values are: debug, info, warn, error") 128 | } 129 | 130 | func initConfig() { 131 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 132 | viper.AutomaticEnv() 133 | 134 | // There is some issue, where the integration of Cobra with Viper will result in wrong values, therefore we are 135 | // setting the values from viper manually. The issue is, that with the standard integration, viper will see, that 136 | // Cobra parameters are set - even if the command line parameter was not used and the default value was set. But 137 | // when Viper notices that the value is set, it will not overwrite the default value with the environment variable. 138 | // Another possibility would be to not have any default values set for cobra command line parameters, but this would 139 | // break the automatic help output from the cli. The manual way here seems the best solution for now. 140 | rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { 141 | if !f.Changed && viper.IsSet(f.Name) { 142 | if err := rootCmd.PersistentFlags().Set(f.Name, fmt.Sprint(viper.Get(f.Name))); err != nil { 143 | log.Fatalf("unable to set value for command line parameter: %v", err) 144 | } 145 | } 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /cmd/webhook/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/stackitcloud/external-dns-stackit-webhook/cmd/webhook/cmd" 4 | 5 | func main() { 6 | err := cmd.Execute() 7 | if err != nil { 8 | panic(err) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stackitcloud/external-dns-stackit-webhook 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.5 6 | 7 | require ( 8 | github.com/goccy/go-json v0.10.2 9 | github.com/gofiber/adaptor/v2 v2.2.1 10 | github.com/gofiber/fiber/v2 v2.52.5 11 | github.com/prometheus/client_golang v1.17.0 12 | github.com/spf13/cobra v1.7.0 13 | github.com/spf13/pflag v1.0.5 14 | github.com/spf13/viper v1.17.0 15 | github.com/stackitcloud/stackit-sdk-go/core v0.10.0 16 | github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.4 17 | github.com/stretchr/testify v1.8.4 18 | go.uber.org/mock v0.3.0 19 | go.uber.org/zap v1.26.0 20 | sigs.k8s.io/external-dns v0.13.6 21 | ) 22 | 23 | require ( 24 | github.com/andybalholm/brotli v1.0.6 // indirect 25 | github.com/aws/aws-sdk-go v1.45.25 // indirect 26 | github.com/beorn7/perks v1.0.1 // indirect 27 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 29 | github.com/fsnotify/fsnotify v1.6.0 // indirect 30 | github.com/go-logr/logr v1.2.4 // indirect 31 | github.com/gogo/protobuf v1.3.2 // indirect 32 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 33 | github.com/golang/protobuf v1.5.3 // indirect 34 | github.com/google/go-cmp v0.6.0 // indirect 35 | github.com/google/gofuzz v1.2.0 // indirect 36 | github.com/google/uuid v1.6.0 // indirect 37 | github.com/hashicorp/hcl v1.0.0 // indirect 38 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 39 | github.com/jmespath/go-jmespath v0.4.0 // indirect 40 | github.com/json-iterator/go v1.1.12 // indirect 41 | github.com/klauspost/compress v1.17.1 // indirect 42 | github.com/magiconair/properties v1.8.7 // indirect 43 | github.com/mattn/go-colorable v0.1.13 // indirect 44 | github.com/mattn/go-isatty v0.0.20 // indirect 45 | github.com/mattn/go-runewidth v0.0.15 // indirect 46 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 47 | github.com/mitchellh/mapstructure v1.5.0 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 51 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 52 | github.com/prometheus/client_model v0.5.0 // indirect 53 | github.com/prometheus/common v0.44.0 // indirect 54 | github.com/prometheus/procfs v0.12.0 // indirect 55 | github.com/rivo/uniseg v0.4.4 // indirect 56 | github.com/rogpeppe/go-internal v1.11.0 // indirect 57 | github.com/sagikazarmark/locafero v0.3.0 // indirect 58 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 59 | github.com/sirupsen/logrus v1.9.3 // indirect 60 | github.com/sourcegraph/conc v0.3.0 // indirect 61 | github.com/spf13/afero v1.10.0 // indirect 62 | github.com/spf13/cast v1.5.1 // indirect 63 | github.com/subosito/gotenv v1.6.0 // indirect 64 | github.com/valyala/bytebufferpool v1.0.0 // indirect 65 | github.com/valyala/fasthttp v1.51.0 // indirect 66 | github.com/valyala/tcplisten v1.0.0 // indirect 67 | go.uber.org/multierr v1.11.0 // indirect 68 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 69 | golang.org/x/net v0.38.0 // indirect 70 | golang.org/x/sys v0.31.0 // indirect 71 | golang.org/x/text v0.23.0 // indirect 72 | google.golang.org/protobuf v1.33.0 // indirect 73 | gopkg.in/inf.v0 v0.9.1 // indirect 74 | gopkg.in/ini.v1 v1.67.0 // indirect 75 | gopkg.in/yaml.v2 v2.4.0 // indirect 76 | gopkg.in/yaml.v3 v3.0.1 // indirect 77 | k8s.io/apimachinery v0.28.2 // indirect 78 | k8s.io/klog/v2 v2.100.1 // indirect 79 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 80 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 81 | sigs.k8s.io/structured-merge-diff/v4 v4.3.0 // indirect 82 | ) 83 | -------------------------------------------------------------------------------- /internal/stackitprovider/apply_changes.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 9 | "go.uber.org/zap" 10 | "sigs.k8s.io/external-dns/endpoint" 11 | "sigs.k8s.io/external-dns/plan" 12 | ) 13 | 14 | // ApplyChanges applies a given set of changes in a given zone. 15 | func (d *StackitDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 16 | var tasks []changeTask 17 | // create rr set. POST /v1/projects/{projectId}/zones/{zoneId}/rrsets 18 | tasks = append(tasks, d.buildRRSetTasks(changes.Create, CREATE)...) 19 | // update rr set. PATCH /v1/projects/{projectId}/zones/{zoneId}/rrsets/{rrSetId} 20 | tasks = append(tasks, d.buildRRSetTasks(changes.UpdateNew, UPDATE)...) 21 | d.logger.Info("records to delete", zap.String("records", fmt.Sprintf("%v", changes.Delete))) 22 | // delete rr set. DELETE /v1/projects/{projectId}/zones/{zoneId}/rrsets/{rrSetId} 23 | tasks = append(tasks, d.buildRRSetTasks(changes.Delete, DELETE)...) 24 | 25 | zones, err := d.zoneFetcherClient.zones(ctx) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return d.handleRRSetWithWorkers(ctx, tasks, zones) 31 | } 32 | 33 | // handleRRSetWithWorkers handles the given endpoints with workers to optimize speed. 34 | func (d *StackitDNSProvider) buildRRSetTasks( 35 | endpoints []*endpoint.Endpoint, 36 | action string, 37 | ) []changeTask { 38 | tasks := make([]changeTask, 0, len(endpoints)) 39 | 40 | for _, change := range endpoints { 41 | tasks = append(tasks, changeTask{ 42 | action: action, 43 | change: change, 44 | }) 45 | } 46 | 47 | return tasks 48 | } 49 | 50 | // handleRRSetWithWorkers handles the given endpoints with workers to optimize speed. 51 | func (d *StackitDNSProvider) handleRRSetWithWorkers( 52 | ctx context.Context, 53 | tasks []changeTask, 54 | zones []stackitdnsclient.Zone, 55 | ) error { 56 | workerChannel := make(chan changeTask, len(tasks)) 57 | errorChannel := make(chan error, len(tasks)) 58 | 59 | var wg sync.WaitGroup 60 | for i := 0; i < d.workers; i++ { 61 | wg.Add(1) 62 | go d.changeWorker(ctx, workerChannel, errorChannel, zones, &wg) 63 | } 64 | 65 | for _, task := range tasks { 66 | workerChannel <- task 67 | } 68 | close(workerChannel) 69 | 70 | // capture first error 71 | var err error 72 | for i := 0; i < len(tasks); i++ { 73 | err = <-errorChannel 74 | if err != nil { 75 | break 76 | } 77 | } 78 | 79 | // wait until all workers have finished 80 | wg.Wait() 81 | 82 | return err 83 | } 84 | 85 | // changeWorker is a worker that handles changes passed by a channel. 86 | func (d *StackitDNSProvider) changeWorker( 87 | ctx context.Context, 88 | changes chan changeTask, 89 | errorChannel chan error, 90 | zones []stackitdnsclient.Zone, 91 | wg *sync.WaitGroup, 92 | ) { 93 | defer wg.Done() 94 | 95 | for change := range changes { 96 | var err error 97 | switch change.action { 98 | case CREATE: 99 | err = d.createRRSet(ctx, change.change, zones) 100 | case UPDATE: 101 | err = d.updateRRSet(ctx, change.change, zones) 102 | case DELETE: 103 | err = d.deleteRRSet(ctx, change.change, zones) 104 | } 105 | errorChannel <- err 106 | } 107 | 108 | d.logger.Debug("change worker finished") 109 | } 110 | 111 | // createRRSet creates a new record set in the stackitprovider for the given endpoint. 112 | func (d *StackitDNSProvider) createRRSet( 113 | ctx context.Context, 114 | change *endpoint.Endpoint, 115 | zones []stackitdnsclient.Zone, 116 | ) error { 117 | resultZone, found := findBestMatchingZone(change.DNSName, zones) 118 | if !found { 119 | return fmt.Errorf("no matching zone found for %s", change.DNSName) 120 | } 121 | 122 | logFields := getLogFields(change, CREATE, *resultZone.Id) 123 | d.logger.Info("create record set", logFields...) 124 | 125 | if d.dryRun { 126 | d.logger.Debug("dry run, skipping", logFields...) 127 | 128 | return nil 129 | } 130 | 131 | modifyChange(change) 132 | 133 | rrSetPayload := getStackitRecordSetPayload(change) 134 | 135 | // ignore all errors to just retry on next run 136 | _, err := d.apiClient.CreateRecordSet(ctx, d.projectId, *resultZone.Id).CreateRecordSetPayload(rrSetPayload).Execute() 137 | if err != nil { 138 | d.logger.Error("error creating record set", zap.Error(err)) 139 | 140 | return err 141 | } 142 | 143 | d.logger.Info("create record set successfully", logFields...) 144 | 145 | return nil 146 | } 147 | 148 | // updateRRSet patches (overrides) contents in the record set in the stackitprovider. 149 | func (d *StackitDNSProvider) updateRRSet( 150 | ctx context.Context, 151 | change *endpoint.Endpoint, 152 | zones []stackitdnsclient.Zone, 153 | ) error { 154 | modifyChange(change) 155 | 156 | resultZone, resultRRSet, err := d.rrSetFetcherClient.getRRSetForUpdateDeletion(ctx, change, zones) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | logFields := getLogFields(change, UPDATE, *resultRRSet.Id) 162 | d.logger.Info("update record set", logFields...) 163 | 164 | if d.dryRun { 165 | d.logger.Debug("dry run, skipping", logFields...) 166 | 167 | return nil 168 | } 169 | 170 | rrSet := getStackitPartialUpdateRecordSetPayload(change) 171 | 172 | _, err = d.apiClient.PartialUpdateRecordSet(ctx, d.projectId, *resultZone.Id, *resultRRSet.Id).PartialUpdateRecordSetPayload(rrSet).Execute() 173 | if err != nil { 174 | d.logger.Error("error updating record set", zap.Error(err)) 175 | 176 | return err 177 | } 178 | 179 | d.logger.Info("update record set successfully", logFields...) 180 | 181 | return nil 182 | } 183 | 184 | // deleteRRSet deletes a record set in the stackitprovider for the given endpoint. 185 | func (d *StackitDNSProvider) deleteRRSet( 186 | ctx context.Context, 187 | change *endpoint.Endpoint, 188 | zones []stackitdnsclient.Zone, 189 | ) error { 190 | modifyChange(change) 191 | 192 | resultZone, resultRRSet, err := d.rrSetFetcherClient.getRRSetForUpdateDeletion(ctx, change, zones) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | logFields := getLogFields(change, DELETE, *resultRRSet.Id) 198 | d.logger.Info("delete record set", logFields...) 199 | 200 | if d.dryRun { 201 | d.logger.Debug("dry run, skipping", logFields...) 202 | 203 | return nil 204 | } 205 | 206 | _, err = d.apiClient.DeleteRecordSet(ctx, d.projectId, *resultZone.Id, *resultRRSet.Id).Execute() 207 | if err != nil { 208 | d.logger.Error("error deleting record set", zap.Error(err)) 209 | 210 | return err 211 | } 212 | 213 | d.logger.Info("delete record set successfully", logFields...) 214 | 215 | return nil 216 | } 217 | -------------------------------------------------------------------------------- /internal/stackitprovider/apply_changes_test.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 12 | "github.com/stretchr/testify/assert" 13 | "sigs.k8s.io/external-dns/endpoint" 14 | "sigs.k8s.io/external-dns/plan" 15 | ) 16 | 17 | type ChangeType int 18 | 19 | const ( 20 | Create ChangeType = iota 21 | Update 22 | Delete 23 | ) 24 | 25 | func TestApplyChanges(t *testing.T) { 26 | t.Parallel() 27 | 28 | testingData := []struct { 29 | changeType ChangeType 30 | }{ 31 | {changeType: Create}, 32 | {changeType: Update}, 33 | {changeType: Delete}, 34 | } 35 | 36 | for _, data := range testingData { 37 | testApplyChanges(t, data.changeType) 38 | } 39 | } 40 | 41 | func testApplyChanges(t *testing.T, changeType ChangeType) { 42 | t.Helper() 43 | ctx := context.Background() 44 | validZoneResponse := getValidResponseZoneAllBytes(t) 45 | validRRSetResponse := getValidResponseRRSetAllBytes(t) 46 | invalidZoneResponse := []byte(`{"invalid: "json"`) 47 | 48 | // Test cases 49 | tests := getApplyChangesBasicTestCases(validZoneResponse, validRRSetResponse, invalidZoneResponse) 50 | 51 | for _, tt := range tests { 52 | tt := tt 53 | t.Run(tt.name, func(t *testing.T) { 54 | t.Parallel() 55 | 56 | mux := http.NewServeMux() 57 | server := httptest.NewServer(mux) 58 | 59 | // Set up common endpoint for all types of changes 60 | setUpCommonEndpoints(mux, tt.responseZone, tt.responseZoneCode) 61 | 62 | // Set up change type-specific endpoints 63 | setUpChangeTypeEndpoints(t, mux, tt.responseRrset, tt.responseRrsetCode, changeType) 64 | 65 | defer server.Close() 66 | 67 | stackitDnsProvider, err := getDefaultTestProvider(server) 68 | assert.NoError(t, err) 69 | 70 | // Set up the changes according to the change type 71 | changes := getChangeTypeChanges(changeType) 72 | 73 | err = stackitDnsProvider.ApplyChanges(ctx, changes) 74 | if tt.expectErr { 75 | assert.Error(t, err) 76 | } else { 77 | assert.NoError(t, err) 78 | } 79 | }) 80 | } 81 | } 82 | 83 | func TestNoMatchingZoneFound(t *testing.T) { 84 | t.Parallel() 85 | 86 | ctx := context.Background() 87 | validZoneResponse := getValidResponseZoneAllBytes(t) 88 | 89 | mux := http.NewServeMux() 90 | server := httptest.NewServer(mux) 91 | defer server.Close() 92 | 93 | // Set up common endpoint for all types of changes 94 | setUpCommonEndpoints(mux, validZoneResponse, http.StatusOK) 95 | 96 | stackitDnsProvider, err := getDefaultTestProvider(server) 97 | assert.NoError(t, err) 98 | 99 | changes := &plan.Changes{ 100 | Create: []*endpoint.Endpoint{ 101 | {DNSName: "notfound.com", Targets: endpoint.Targets{"test.notfound.com"}}, 102 | }, 103 | UpdateNew: []*endpoint.Endpoint{}, 104 | Delete: []*endpoint.Endpoint{}, 105 | } 106 | 107 | err = stackitDnsProvider.ApplyChanges(ctx, changes) 108 | assert.Error(t, err) 109 | } 110 | 111 | func TestNoRRSetFound(t *testing.T) { 112 | t.Parallel() 113 | 114 | ctx := context.Background() 115 | validZoneResponse := getValidResponseZoneAllBytes(t) 116 | rrSets := getValidResponseRRSetAll() 117 | rrSet := *rrSets.RrSets 118 | *rrSet[0].Name = "notfound.test.com" 119 | validRRSetResponse, err := json.Marshal(rrSets) 120 | assert.NoError(t, err) 121 | 122 | mux := http.NewServeMux() 123 | server := httptest.NewServer(mux) 124 | defer server.Close() 125 | 126 | // Set up common endpoint for all types of changes 127 | setUpCommonEndpoints(mux, validZoneResponse, http.StatusOK) 128 | 129 | mux.HandleFunc( 130 | "/v1/projects/1234/zones/1234/rrsets", 131 | responseHandler(validRRSetResponse, http.StatusOK), 132 | ) 133 | 134 | stackitDnsProvider, err := getDefaultTestProvider(server) 135 | assert.NoError(t, err) 136 | 137 | changes := &plan.Changes{ 138 | UpdateNew: []*endpoint.Endpoint{ 139 | {DNSName: "test.com", Targets: endpoint.Targets{"notfound.test.com"}}, 140 | }, 141 | } 142 | 143 | err = stackitDnsProvider.ApplyChanges(ctx, changes) 144 | assert.Error(t, err) 145 | } 146 | 147 | func TestPartialUpdate(t *testing.T) { 148 | t.Parallel() 149 | 150 | ctx := context.Background() 151 | validZoneResponse := getValidResponseZoneAllBytes(t) 152 | 153 | mux := http.NewServeMux() 154 | server := httptest.NewServer(mux) 155 | defer server.Close() 156 | 157 | // Set up common endpoint for all types of changes 158 | setUpCommonEndpoints(mux, validZoneResponse, http.StatusOK) 159 | // Set up change type-specific endpoints 160 | // based on setUpChangeTypeEndpoints(t, mux, validRRSetResponse, http.StatusOK, Update) 161 | // but extended to check that the rrset is updated 162 | rrSetUpdated := false 163 | mux.HandleFunc( 164 | "/v1/projects/1234/zones/1234/rrsets/1234", 165 | func(w http.ResponseWriter, r *http.Request) { 166 | w.Header().Set("Content-Type", "application/json") 167 | w.WriteHeader(http.StatusOK) 168 | fmt.Println(r.Method) 169 | if r.Method == http.MethodPatch { 170 | rrSetUpdated = true 171 | } 172 | }, 173 | ) 174 | mux.HandleFunc( 175 | "/v1/projects/1234/zones/1234/rrsets", 176 | func(w http.ResponseWriter, r *http.Request) { 177 | getRrsetsResponseRecordsNonPaged(t, w, "1234") 178 | }, 179 | ) 180 | mux.HandleFunc( 181 | "/v1/projects/1234/zones/5678/rrsets", 182 | func(w http.ResponseWriter, r *http.Request) { 183 | getRrsetsResponseRecordsNonPaged(t, w, "5678") 184 | }, 185 | ) 186 | 187 | stackitDnsProvider, err := getDefaultTestProvider(server) 188 | assert.NoError(t, err) 189 | 190 | // Create update change 191 | changes := getChangeTypeChanges(Update) 192 | // Add task to create invalid endpoint 193 | changes.Create = []*endpoint.Endpoint{ 194 | {DNSName: "notfound.com", Targets: endpoint.Targets{"test.notfound.com"}}, 195 | } 196 | 197 | err = stackitDnsProvider.ApplyChanges(ctx, changes) 198 | assert.Error(t, err) 199 | assert.True(t, rrSetUpdated, "rrset was not updated") 200 | } 201 | 202 | // setUpCommonEndpoints for all change types. 203 | func setUpCommonEndpoints(mux *http.ServeMux, responseZone []byte, responseZoneCode int) { 204 | mux.HandleFunc("/v1/projects/1234/zones", func(w http.ResponseWriter, r *http.Request) { 205 | w.Header().Set("Content-Type", "application/json") 206 | w.WriteHeader(responseZoneCode) 207 | w.Write(responseZone) 208 | }) 209 | } 210 | 211 | // setUpChangeTypeEndpoints for type-specific endpoints. 212 | func setUpChangeTypeEndpoints( 213 | t *testing.T, 214 | mux *http.ServeMux, 215 | responseRrset []byte, 216 | responseRrsetCode int, 217 | changeType ChangeType, 218 | ) { 219 | t.Helper() 220 | 221 | switch changeType { 222 | case Create: 223 | mux.HandleFunc( 224 | "/v1/projects/1234/zones/1234/rrsets", 225 | responseHandler(responseRrset, responseRrsetCode), 226 | ) 227 | case Update, Delete: 228 | mux.HandleFunc( 229 | "/v1/projects/1234/zones/1234/rrsets/1234", 230 | responseHandler(nil, responseRrsetCode), 231 | ) 232 | mux.HandleFunc( 233 | "/v1/projects/1234/zones/1234/rrsets", 234 | func(w http.ResponseWriter, r *http.Request) { 235 | getRrsetsResponseRecordsNonPaged(t, w, "1234") 236 | }, 237 | ) 238 | mux.HandleFunc( 239 | "/v1/projects/1234/zones/5678/rrsets", 240 | func(w http.ResponseWriter, r *http.Request) { 241 | getRrsetsResponseRecordsNonPaged(t, w, "5678") 242 | }, 243 | ) 244 | } 245 | } 246 | 247 | // getChangeTypeChanges according to the change type. 248 | func getChangeTypeChanges(changeType ChangeType) *plan.Changes { 249 | switch changeType { 250 | case Create: 251 | return &plan.Changes{ 252 | Create: []*endpoint.Endpoint{ 253 | {DNSName: "test.com", Targets: endpoint.Targets{"test.test.com"}}, 254 | }, 255 | UpdateNew: []*endpoint.Endpoint{}, 256 | Delete: []*endpoint.Endpoint{}, 257 | } 258 | case Update: 259 | return &plan.Changes{ 260 | UpdateNew: []*endpoint.Endpoint{ 261 | {DNSName: "test.com", Targets: endpoint.Targets{"test.com"}, RecordType: "A"}, 262 | }, 263 | } 264 | case Delete: 265 | return &plan.Changes{ 266 | Delete: []*endpoint.Endpoint{ 267 | {DNSName: "test.com", Targets: endpoint.Targets{"test.com"}, RecordType: "A"}, 268 | }, 269 | } 270 | default: 271 | return nil 272 | } 273 | } 274 | 275 | func getApplyChangesBasicTestCases( 276 | validZoneResponse []byte, 277 | validRRSetResponse []byte, 278 | invalidZoneResponse []byte, 279 | ) []struct { 280 | name string 281 | responseZone []byte 282 | responseZoneCode int 283 | responseRrset []byte 284 | responseRrsetCode int 285 | expectErr bool 286 | expectedRrsetMethod string 287 | } { 288 | tests := []struct { 289 | name string 290 | responseZone []byte 291 | responseZoneCode int 292 | responseRrset []byte 293 | responseRrsetCode int 294 | expectErr bool 295 | expectedRrsetMethod string 296 | }{ 297 | { 298 | "Valid response", 299 | validZoneResponse, 300 | http.StatusOK, 301 | validRRSetResponse, 302 | http.StatusAccepted, 303 | false, 304 | http.MethodPost, 305 | }, 306 | { 307 | "Zone response 403", 308 | nil, 309 | http.StatusForbidden, 310 | validRRSetResponse, 311 | http.StatusAccepted, 312 | true, 313 | "", 314 | }, 315 | { 316 | "Zone response 500", 317 | nil, 318 | http.StatusInternalServerError, 319 | validRRSetResponse, 320 | http.StatusAccepted, 321 | true, 322 | "", 323 | }, 324 | { 325 | "Zone response Invalid JSON", 326 | invalidZoneResponse, 327 | http.StatusOK, 328 | validRRSetResponse, 329 | http.StatusAccepted, 330 | true, 331 | "", 332 | }, 333 | { 334 | "Zone response, Rrset response 403", 335 | validZoneResponse, 336 | http.StatusOK, 337 | nil, 338 | http.StatusForbidden, 339 | true, 340 | http.MethodPost, 341 | }, 342 | { 343 | "Zone response, Rrset response 500", 344 | validZoneResponse, 345 | http.StatusOK, 346 | nil, 347 | http.StatusInternalServerError, 348 | true, 349 | http.MethodPost, 350 | }, 351 | } 352 | 353 | return tests 354 | } 355 | 356 | func responseHandler(responseBody []byte, statusCode int) func(http.ResponseWriter, *http.Request) { 357 | return func(w http.ResponseWriter, r *http.Request) { 358 | w.Header().Set("Content-Type", "application/json") 359 | w.WriteHeader(statusCode) 360 | if responseBody != nil { 361 | w.Write(responseBody) 362 | } 363 | } 364 | } 365 | 366 | func getValidResponseZoneAllBytes(t *testing.T) []byte { 367 | t.Helper() 368 | 369 | zones := getValidZoneResponseAll() 370 | validZoneResponse, err := json.Marshal(zones) 371 | assert.NoError(t, err) 372 | 373 | return validZoneResponse 374 | } 375 | 376 | func getValidZoneResponseAll() stackitdnsclient.ListZonesResponse { 377 | return stackitdnsclient.ListZonesResponse{ 378 | ItemsPerPage: pointerTo(int64(10)), 379 | Message: pointerTo("success"), 380 | TotalItems: pointerTo(int64(2)), 381 | TotalPages: pointerTo(int64(1)), 382 | Zones: &[]stackitdnsclient.Zone{ 383 | {Id: pointerTo("1234"), DnsName: pointerTo("test.com")}, 384 | {Id: pointerTo("5678"), DnsName: pointerTo("test2.com")}, 385 | }, 386 | } 387 | } 388 | 389 | func getValidResponseRRSetAllBytes(t *testing.T) []byte { 390 | t.Helper() 391 | 392 | rrSets := getValidResponseRRSetAll() 393 | validRRSetResponse, err := json.Marshal(rrSets) 394 | assert.NoError(t, err) 395 | 396 | return validRRSetResponse 397 | } 398 | 399 | func getValidResponseRRSetAll() stackitdnsclient.ListRecordSetsResponse { 400 | return stackitdnsclient.ListRecordSetsResponse{ 401 | ItemsPerPage: pointerTo(int64(20)), 402 | Message: pointerTo("success"), 403 | RrSets: &[]stackitdnsclient.RecordSet{ 404 | { 405 | Name: pointerTo("test.com"), 406 | Type: pointerTo("A"), 407 | Ttl: pointerTo(int64(300)), 408 | Records: &[]stackitdnsclient.Record{ 409 | {Content: pointerTo("1.2.3.4")}, 410 | }, 411 | }, 412 | }, 413 | TotalItems: pointerTo(int64(2)), 414 | TotalPages: pointerTo(int64(1)), 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /internal/stackitprovider/config.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import "sigs.k8s.io/external-dns/endpoint" 4 | 5 | // Config is used to configure the creation of the StackitDNSProvider. 6 | type Config struct { 7 | ProjectId string 8 | DomainFilter endpoint.DomainFilter 9 | DryRun bool 10 | Workers int 11 | } 12 | -------------------------------------------------------------------------------- /internal/stackitprovider/domain_filter.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import "sigs.k8s.io/external-dns/endpoint" 4 | 5 | func (d *StackitDNSProvider) GetDomainFilter() endpoint.DomainFilter { 6 | return d.domainFilter 7 | } 8 | -------------------------------------------------------------------------------- /internal/stackitprovider/domain_filter_test.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "sigs.k8s.io/external-dns/endpoint" 8 | ) 9 | 10 | func TestGetDomainFilter(t *testing.T) { 11 | t.Parallel() 12 | 13 | server := getServerRecords(t) 14 | defer server.Close() 15 | 16 | stackitDnsProvider, err := getDefaultTestProvider(server) 17 | assert.NoError(t, err) 18 | 19 | domainFilter := stackitDnsProvider.GetDomainFilter() 20 | assert.Equal(t, domainFilter, endpoint.DomainFilter{}) 21 | } 22 | -------------------------------------------------------------------------------- /internal/stackitprovider/helper.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "strings" 5 | 6 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 7 | "go.uber.org/zap" 8 | "sigs.k8s.io/external-dns/endpoint" 9 | ) 10 | 11 | // findBestMatchingZone finds the best matching zone for a given record set name. The criteria are 12 | // that the zone name is contained in the record set name and that the zone name is the longest 13 | // possible match. Eg foo.bar.com. would have precedence over bar.com. if rr set name is foo.bar.com. 14 | func findBestMatchingZone( 15 | rrSetName string, 16 | zones []stackitdnsclient.Zone, 17 | ) (*stackitdnsclient.Zone, bool) { 18 | count := 0 19 | var domainZone stackitdnsclient.Zone 20 | for _, zone := range zones { 21 | if len(*zone.DnsName) > count && strings.Contains(rrSetName, *zone.DnsName) { 22 | count = len(*zone.DnsName) 23 | domainZone = zone 24 | } 25 | } 26 | 27 | if count == 0 { 28 | return nil, false 29 | } 30 | 31 | return &domainZone, true 32 | } 33 | 34 | // findRRSet finds a record set by name and type in a list of record sets. 35 | func findRRSet( 36 | rrSetName, recordType string, 37 | rrSets []stackitdnsclient.RecordSet, 38 | ) (*stackitdnsclient.RecordSet, bool) { 39 | for _, rrSet := range rrSets { 40 | if *rrSet.Name == rrSetName && *rrSet.Type == recordType { 41 | return &rrSet, true 42 | } 43 | } 44 | 45 | return nil, false 46 | } 47 | 48 | // appendDotIfNotExists appends a dot to the end of a string if it doesn't already end with a dot. 49 | func appendDotIfNotExists(s string) string { 50 | if !strings.HasSuffix(s, ".") { 51 | return s + "." 52 | } 53 | 54 | return s 55 | } 56 | 57 | // modifyChange modifies a change to ensure it is valid for this stackitprovider. 58 | func modifyChange(change *endpoint.Endpoint) { 59 | change.DNSName = appendDotIfNotExists(change.DNSName) 60 | 61 | if change.RecordTTL == 0 { 62 | change.RecordTTL = 300 63 | } 64 | } 65 | 66 | // getStackitRecordSetPayload returns a stackitdnsclient.RecordSetPayload from a change for the api client. 67 | func getStackitRecordSetPayload(change *endpoint.Endpoint) stackitdnsclient.CreateRecordSetPayload { 68 | records := make([]stackitdnsclient.RecordPayload, len(change.Targets)) 69 | for i := range change.Targets { 70 | records[i] = stackitdnsclient.RecordPayload{ 71 | Content: &change.Targets[i], 72 | } 73 | } 74 | 75 | return stackitdnsclient.CreateRecordSetPayload{ 76 | Name: &change.DNSName, 77 | Records: &records, 78 | Ttl: pointerTo(int64(change.RecordTTL)), 79 | Type: &change.RecordType, 80 | } 81 | } 82 | 83 | // getStackitPartialUpdateRecordSetPayload returns a stackitdnsclient.PartialUpdateRecordSetPayload from a change for the api client. 84 | func getStackitPartialUpdateRecordSetPayload(change *endpoint.Endpoint) stackitdnsclient.PartialUpdateRecordSetPayload { 85 | records := make([]stackitdnsclient.RecordPayload, len(change.Targets)) 86 | for i := range change.Targets { 87 | records[i] = stackitdnsclient.RecordPayload{ 88 | Content: &change.Targets[i], 89 | } 90 | } 91 | 92 | return stackitdnsclient.PartialUpdateRecordSetPayload{ 93 | Name: &change.DNSName, 94 | Records: &records, 95 | Ttl: pointerTo(int64(change.RecordTTL)), 96 | } 97 | } 98 | 99 | // getLogFields returns a log.Fields object for a change. 100 | func getLogFields(change *endpoint.Endpoint, action string, id string) []zap.Field { 101 | return []zap.Field{ 102 | zap.String("record", change.DNSName), 103 | zap.String("content", strings.Join(change.Targets, ",")), 104 | zap.String("type", change.RecordType), 105 | zap.String("action", action), 106 | zap.String("id", id), 107 | } 108 | } 109 | 110 | // pointerTo returns a pointer to the given value. 111 | func pointerTo[T any](v T) *T { 112 | return &v 113 | } 114 | -------------------------------------------------------------------------------- /internal/stackitprovider/helper_test.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 8 | "go.uber.org/zap" 9 | "sigs.k8s.io/external-dns/endpoint" 10 | ) 11 | 12 | func TestAppendDotIfNotExists(t *testing.T) { 13 | t.Parallel() 14 | 15 | tests := []struct { 16 | name string 17 | s string 18 | want string 19 | }{ 20 | {"No dot at end", "test", "test."}, 21 | {"Dot at end", "test.", "test."}, 22 | } 23 | for _, tt := range tests { 24 | tt := tt 25 | t.Run(tt.name, func(t *testing.T) { 26 | t.Parallel() 27 | 28 | if got := appendDotIfNotExists(tt.s); got != tt.want { 29 | t.Errorf("appendDotIfNotExists() = %v, want %v", got, tt.want) 30 | } 31 | }) 32 | } 33 | } 34 | 35 | func TestModifyChange(t *testing.T) { 36 | t.Parallel() 37 | 38 | endpointWithTTL := &endpoint.Endpoint{ 39 | DNSName: "test", 40 | RecordTTL: endpoint.TTL(400), 41 | } 42 | modifyChange(endpointWithTTL) 43 | if endpointWithTTL.DNSName != "test." { 44 | t.Errorf("modifyChange() did not append dot to DNSName = %v, want test.", endpointWithTTL.DNSName) 45 | } 46 | if endpointWithTTL.RecordTTL != 400 { 47 | t.Errorf("modifyChange() changed existing RecordTTL = %v, want 400", endpointWithTTL.RecordTTL) 48 | } 49 | 50 | endpointWithoutTTL := &endpoint.Endpoint{ 51 | DNSName: "test", 52 | } 53 | modifyChange(endpointWithoutTTL) 54 | if endpointWithoutTTL.DNSName != "test." { 55 | t.Errorf("modifyChange() did not append dot to DNSName = %v, want test.", endpointWithoutTTL.DNSName) 56 | } 57 | if endpointWithoutTTL.RecordTTL != 300 { 58 | t.Errorf("modifyChange() did not set default RecordTTL = %v, want 300", endpointWithoutTTL.RecordTTL) 59 | } 60 | } 61 | 62 | func TestGetStackitRRSetRecordPost(t *testing.T) { 63 | t.Parallel() 64 | 65 | change := &endpoint.Endpoint{ 66 | DNSName: "test.", 67 | RecordTTL: endpoint.TTL(300), 68 | RecordType: "A", 69 | Targets: endpoint.Targets{ 70 | "192.0.2.1", 71 | "192.0.2.2", 72 | }, 73 | } 74 | expected := stackitdnsclient.CreateRecordSetPayload{ 75 | Name: pointerTo("test."), 76 | Ttl: pointerTo(int64(300)), 77 | Type: pointerTo("A"), 78 | Records: &[]stackitdnsclient.RecordPayload{ 79 | { 80 | Content: pointerTo("192.0.2.1"), 81 | }, 82 | { 83 | Content: pointerTo("192.0.2.2"), 84 | }, 85 | }, 86 | } 87 | got := getStackitRecordSetPayload(change) 88 | if !reflect.DeepEqual(got, expected) { 89 | t.Errorf("getStackitRRSetRecordPost() = %v, want %v", got, expected) 90 | } 91 | } 92 | 93 | func TestFindBestMatchingZone(t *testing.T) { 94 | t.Parallel() 95 | 96 | zones := []stackitdnsclient.Zone{ 97 | {DnsName: pointerTo("foo.com")}, 98 | {DnsName: pointerTo("bar.com")}, 99 | {DnsName: pointerTo("baz.com")}, 100 | } 101 | 102 | tests := []struct { 103 | name string 104 | rrSetName string 105 | want *stackitdnsclient.Zone 106 | wantFound bool 107 | }{ 108 | {"Matching Zone", "www.foo.com", &zones[0], true}, 109 | {"No Matching Zone", "www.test.com", nil, false}, 110 | } 111 | 112 | for _, tt := range tests { 113 | tt := tt 114 | t.Run(tt.name, func(t *testing.T) { 115 | t.Parallel() 116 | got, found := findBestMatchingZone(tt.rrSetName, zones) 117 | if !reflect.DeepEqual(got, tt.want) || found != tt.wantFound { 118 | t.Errorf("findBestMatchingZone() = %v, %v, want %v, %v", got, found, tt.want, tt.wantFound) 119 | } 120 | }) 121 | } 122 | } 123 | 124 | func TestFindRRSet(t *testing.T) { 125 | t.Parallel() 126 | 127 | rrSets := []stackitdnsclient.RecordSet{ 128 | {Name: pointerTo("www.foo.com"), Type: pointerTo("A")}, 129 | {Name: pointerTo("www.bar.com"), Type: pointerTo("A")}, 130 | {Name: pointerTo("www.baz.com"), Type: pointerTo("A")}, 131 | } 132 | 133 | tests := []struct { 134 | name string 135 | rrSetName string 136 | recordType string 137 | want *stackitdnsclient.RecordSet 138 | wantFound bool 139 | }{ 140 | {"Matching RRSet", "www.foo.com", "A", &rrSets[0], true}, 141 | {"No Matching RRSet", "www.test.com", "A", nil, false}, 142 | } 143 | 144 | for _, tt := range tests { 145 | tt := tt 146 | t.Run(tt.name, func(t *testing.T) { 147 | t.Parallel() 148 | 149 | got, found := findRRSet(tt.rrSetName, tt.recordType, rrSets) 150 | if !reflect.DeepEqual(got, tt.want) || found != tt.wantFound { 151 | t.Errorf("findRRSet() = %v, %v, want %v, %v", got, found, tt.want, tt.wantFound) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | func TestGetLogFields(t *testing.T) { 158 | t.Parallel() 159 | 160 | change := &endpoint.Endpoint{ 161 | DNSName: "test.", 162 | RecordTTL: endpoint.TTL(300), 163 | RecordType: "A", 164 | Targets: endpoint.Targets{ 165 | "192.0.2.1", 166 | "192.0.2.2", 167 | }, 168 | } 169 | 170 | expected := []zap.Field{ 171 | zap.String("record", "test."), 172 | zap.String("content", "192.0.2.1,192.0.2.2"), 173 | zap.String("type", "A"), 174 | zap.String("action", "create"), 175 | zap.String("id", "123"), 176 | } 177 | 178 | got := getLogFields(change, "create", "123") 179 | 180 | if !reflect.DeepEqual(got, expected) { 181 | t.Errorf("getLogFields() = %v, want %v", got, expected) 182 | } 183 | } 184 | 185 | func TestGetStackitRRSetRecordPatch(t *testing.T) { 186 | t.Parallel() 187 | 188 | change := &endpoint.Endpoint{ 189 | DNSName: "test.", 190 | RecordTTL: endpoint.TTL(300), 191 | RecordType: "A", 192 | Targets: endpoint.Targets{ 193 | "192.0.2.1", 194 | "192.0.2.2", 195 | }, 196 | } 197 | 198 | expected := stackitdnsclient.PartialUpdateRecordSetPayload{ 199 | Name: pointerTo("test."), 200 | Ttl: pointerTo(int64(300)), 201 | Records: &[]stackitdnsclient.RecordPayload{ 202 | { 203 | Content: pointerTo("192.0.2.1"), 204 | }, 205 | { 206 | Content: pointerTo("192.0.2.2"), 207 | }, 208 | }, 209 | } 210 | 211 | got := getStackitPartialUpdateRecordSetPayload(change) 212 | 213 | if !reflect.DeepEqual(got, expected) { 214 | t.Errorf("getStackitRRSetRecordPatch() = %v, want %v", got, expected) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /internal/stackitprovider/models.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import "sigs.k8s.io/external-dns/endpoint" 4 | 5 | const ( 6 | CREATE = "CREATE" 7 | UPDATE = "UPDATE" 8 | DELETE = "DELETE" 9 | ) 10 | 11 | // ErrorMessage is the error message returned by the API. 12 | type ErrorMessage struct { 13 | Message string `json:"message"` 14 | } 15 | 16 | // changeTask is a task that is passed to the worker. 17 | type changeTask struct { 18 | change *endpoint.Endpoint 19 | action string 20 | } 21 | 22 | // endpointError is a list of endpoints and an error to pass to workers. 23 | type endpointError struct { 24 | endpoints []*endpoint.Endpoint 25 | err error 26 | } 27 | -------------------------------------------------------------------------------- /internal/stackitprovider/records.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "context" 5 | 6 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 7 | "sigs.k8s.io/external-dns/endpoint" 8 | "sigs.k8s.io/external-dns/provider" 9 | ) 10 | 11 | // Records returns resource records. 12 | func (d *StackitDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 13 | zones, err := d.zoneFetcherClient.zones(ctx) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | var endpoints []*endpoint.Endpoint 19 | endpointsErrorChannel := make(chan endpointError, len(zones)) 20 | zoneIdsChannel := make(chan string, len(zones)) 21 | 22 | for i := 0; i < d.workers; i++ { 23 | go d.fetchRecordsWorker(ctx, zoneIdsChannel, endpointsErrorChannel) 24 | } 25 | 26 | for _, zone := range zones { 27 | zoneIdsChannel <- *zone.Id 28 | } 29 | 30 | for i := 0; i < len(zones); i++ { 31 | endpointsErrorList := <-endpointsErrorChannel 32 | if endpointsErrorList.err != nil { 33 | close(zoneIdsChannel) 34 | 35 | return nil, endpointsErrorList.err 36 | } 37 | endpoints = append(endpoints, endpointsErrorList.endpoints...) 38 | } 39 | 40 | close(zoneIdsChannel) 41 | 42 | return endpoints, nil 43 | } 44 | 45 | // fetchRecordsWorker fetches all records from a given zone. 46 | func (d *StackitDNSProvider) fetchRecordsWorker( 47 | ctx context.Context, 48 | zoneIdChannel chan string, 49 | endpointsErrorChannel chan<- endpointError, 50 | ) { 51 | for zoneId := range zoneIdChannel { 52 | d.processZoneRRSets(ctx, zoneId, endpointsErrorChannel) 53 | } 54 | 55 | d.logger.Debug("fetch record set worker finished") 56 | } 57 | 58 | // processZoneRRSets fetches and processes DNS records for a given zone. 59 | func (d *StackitDNSProvider) processZoneRRSets( 60 | ctx context.Context, 61 | zoneId string, 62 | endpointsErrorChannel chan<- endpointError, 63 | ) { 64 | var endpoints []*endpoint.Endpoint 65 | rrSets, err := d.rrSetFetcherClient.fetchRecords(ctx, zoneId, nil) 66 | if err != nil { 67 | endpointsErrorChannel <- endpointError{ 68 | endpoints: nil, 69 | err: err, 70 | } 71 | 72 | return 73 | } 74 | 75 | endpoints = d.collectEndPoints(rrSets) 76 | endpointsErrorChannel <- endpointError{ 77 | endpoints: endpoints, 78 | err: nil, 79 | } 80 | } 81 | 82 | // collectEndPoints creates a list of Endpoints from the provided rrSets. 83 | func (d *StackitDNSProvider) collectEndPoints( 84 | rrSets []stackitdnsclient.RecordSet, 85 | ) []*endpoint.Endpoint { 86 | var endpoints []*endpoint.Endpoint 87 | for _, r := range rrSets { 88 | if provider.SupportedRecordType(*r.Type) { 89 | for _, _r := range *r.Records { 90 | endpoints = append( 91 | endpoints, 92 | endpoint.NewEndpointWithTTL( 93 | *r.Name, 94 | *r.Type, 95 | endpoint.TTL(*r.Ttl), 96 | *_r.Content, 97 | ), 98 | ) 99 | } 100 | } 101 | } 102 | 103 | return endpoints 104 | } 105 | -------------------------------------------------------------------------------- /internal/stackitprovider/records_test.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/goccy/go-json" 10 | stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" 11 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 12 | "github.com/stretchr/testify/assert" 13 | "go.uber.org/zap" 14 | "sigs.k8s.io/external-dns/endpoint" 15 | ) 16 | 17 | func TestRecords(t *testing.T) { 18 | t.Parallel() 19 | 20 | server := getServerRecords(t) 21 | defer server.Close() 22 | 23 | stackitDnsProvider, err := getDefaultTestProvider(server) 24 | assert.NoError(t, err) 25 | 26 | endpoints, err := stackitDnsProvider.Records(context.Background()) 27 | assert.NoError(t, err) 28 | assert.Equal(t, 2, len(endpoints)) 29 | assert.Equal(t, "test.com", endpoints[0].DNSName) 30 | assert.Equal(t, "A", endpoints[0].RecordType) 31 | assert.Equal(t, "1.2.3.4", endpoints[0].Targets[0]) 32 | assert.Equal(t, int64(300), int64(endpoints[0].RecordTTL)) 33 | 34 | assert.Equal(t, "test2.com", endpoints[1].DNSName) 35 | assert.Equal(t, "A", endpoints[1].RecordType) 36 | assert.Equal(t, "5.6.7.8", endpoints[1].Targets[0]) 37 | assert.Equal(t, int64(300), int64(endpoints[1].RecordTTL)) 38 | } 39 | 40 | // TestWrongJsonResponseRecords tests the scenario where the server returns a wrong JSON response. 41 | func TestWrongJsonResponseRecords(t *testing.T) { 42 | t.Parallel() 43 | 44 | mux := http.NewServeMux() 45 | server := httptest.NewServer(mux) 46 | 47 | mux.HandleFunc("/v1/projects/1234/zones", 48 | func(w http.ResponseWriter, r *http.Request) { 49 | w.Header().Set("Content-Type", "application/json") 50 | 51 | w.WriteHeader(http.StatusOK) 52 | w.Write([]byte(`{"invalid:"json"`)) // This is not a valid JSON. 53 | }, 54 | ) 55 | defer server.Close() 56 | 57 | stackitDnsProvider, err := getDefaultTestProvider(server) 58 | assert.NoError(t, err) 59 | 60 | endpoints, err := stackitDnsProvider.Records(context.Background()) 61 | assert.Error(t, err) 62 | assert.Equal(t, 0, len(endpoints)) 63 | } 64 | 65 | // TestPagedResponseRecords tests the scenario where the server returns a paged response. 66 | func TestPagedResponseRecords(t *testing.T) { 67 | t.Parallel() 68 | 69 | server := getPagedRecordsServer(t) 70 | defer server.Close() 71 | 72 | stackitDnsProvider, err := getDefaultTestProvider(server) 73 | assert.NoError(t, err) 74 | 75 | endpoints, err := stackitDnsProvider.Records(context.Background()) 76 | assert.NoError(t, err) 77 | assert.Equal(t, 2, len(endpoints)) 78 | assert.Equal(t, "test.com", endpoints[0].DNSName) 79 | assert.Equal(t, "A", endpoints[0].RecordType) 80 | assert.Equal(t, "1.2.3.4", endpoints[0].Targets[0]) 81 | assert.Equal(t, int64(300), int64(endpoints[0].RecordTTL)) 82 | 83 | assert.Equal(t, "test2.com", endpoints[1].DNSName) 84 | assert.Equal(t, "A", endpoints[1].RecordType) 85 | assert.Equal(t, "5.6.7.8", endpoints[1].Targets[0]) 86 | assert.Equal(t, int64(300), int64(endpoints[1].RecordTTL)) 87 | } 88 | 89 | func TestEmptyZonesRouteRecords(t *testing.T) { 90 | t.Parallel() 91 | 92 | mux := http.NewServeMux() 93 | server := httptest.NewServer(mux) 94 | defer server.Close() 95 | 96 | stackitDnsProvider, err := getDefaultTestProvider(server) 97 | assert.NoError(t, err) 98 | 99 | _, err = stackitDnsProvider.Records(context.Background()) 100 | assert.Error(t, err) 101 | } 102 | 103 | func TestEmptyRRSetRouteRecords(t *testing.T) { 104 | t.Parallel() 105 | 106 | mux := http.NewServeMux() 107 | server := httptest.NewServer(mux) 108 | mux.HandleFunc("/v1/projects/1234/zones", 109 | func(w http.ResponseWriter, r *http.Request) { 110 | w.Header().Set("Content-Type", "application/json") 111 | 112 | zones := stackitdnsclient.ListZonesResponse{ 113 | ItemsPerPage: pointerTo(int64(1)), 114 | Message: pointerTo("success"), 115 | TotalItems: pointerTo(int64(2)), 116 | TotalPages: pointerTo(int64(2)), 117 | Zones: &[]stackitdnsclient.Zone{{Id: pointerTo("1234")}}, 118 | } 119 | successResponseBytes, err := json.Marshal(zones) 120 | assert.NoError(t, err) 121 | 122 | w.WriteHeader(http.StatusOK) 123 | w.Write(successResponseBytes) 124 | }, 125 | ) 126 | defer server.Close() 127 | 128 | stackitDnsProvider, err := getDefaultTestProvider(server) 129 | assert.NoError(t, err) 130 | 131 | _, err = stackitDnsProvider.Records(context.Background()) 132 | assert.Error(t, err) 133 | } 134 | 135 | func TestZoneEndpoint500Records(t *testing.T) { 136 | t.Parallel() 137 | 138 | mux := http.NewServeMux() 139 | server := httptest.NewServer(mux) 140 | mux.HandleFunc("/v1/projects/1234/zones", 141 | func(w http.ResponseWriter, r *http.Request) { 142 | w.Header().Set("Content-Type", "application/json") 143 | 144 | w.WriteHeader(http.StatusInternalServerError) 145 | }, 146 | ) 147 | defer server.Close() 148 | 149 | stackitDnsProvider, err := getDefaultTestProvider(server) 150 | assert.NoError(t, err) 151 | 152 | _, err = stackitDnsProvider.Records(context.Background()) 153 | assert.Error(t, err) 154 | } 155 | 156 | func TestZoneEndpoint403Records(t *testing.T) { 157 | t.Parallel() 158 | 159 | mux := http.NewServeMux() 160 | server := httptest.NewServer(mux) 161 | mux.HandleFunc("/v1/projects/1234/zones", 162 | func(w http.ResponseWriter, r *http.Request) { 163 | w.Header().Set("Content-Type", "application/json") 164 | 165 | w.WriteHeader(http.StatusForbidden) 166 | }, 167 | ) 168 | defer server.Close() 169 | 170 | stackitDnsProvider, err := NewStackitDNSProvider( 171 | zap.NewNop(), 172 | Config{ 173 | ProjectId: "1234", 174 | DomainFilter: endpoint.DomainFilter{}, 175 | DryRun: false, 176 | Workers: 10, 177 | }, 178 | stackitconfig.WithHTTPClient(server.Client()), 179 | stackitconfig.WithEndpoint(server.URL), 180 | // we need a non-empty token for the bootstrapping not to fail 181 | stackitconfig.WithToken("token"), 182 | ) 183 | assert.NoError(t, err) 184 | 185 | _, err = stackitDnsProvider.Records(context.Background()) 186 | assert.Error(t, err) 187 | } 188 | 189 | func getDefaultTestProvider(server *httptest.Server) (*StackitDNSProvider, error) { 190 | stackitDnsProvider, err := NewStackitDNSProvider( 191 | zap.NewNop(), 192 | Config{ 193 | ProjectId: "1234", 194 | DomainFilter: endpoint.DomainFilter{}, 195 | DryRun: false, 196 | Workers: 1, 197 | }, 198 | stackitconfig.WithHTTPClient(server.Client()), 199 | stackitconfig.WithEndpoint(server.URL), 200 | // we need a non-empty token for the bootstrapping not to fail 201 | stackitconfig.WithToken("token")) 202 | 203 | return stackitDnsProvider, err 204 | } 205 | 206 | func getZonesHandlerRecordsPaged(t *testing.T) http.HandlerFunc { 207 | t.Helper() 208 | 209 | return func(w http.ResponseWriter, r *http.Request) { 210 | w.Header().Set("Content-Type", "application/json") 211 | 212 | zones := stackitdnsclient.ListZonesResponse{} 213 | if r.URL.Query().Get("page") == "1" { 214 | zones = stackitdnsclient.ListZonesResponse{ 215 | ItemsPerPage: pointerTo(int64(1)), 216 | Message: pointerTo("success"), 217 | TotalItems: pointerTo(int64(2)), 218 | TotalPages: pointerTo(int64(2)), 219 | Zones: &[]stackitdnsclient.Zone{{Id: pointerTo("1234")}}, 220 | } 221 | } 222 | if r.URL.Query().Get("page") == "2" { 223 | zones = stackitdnsclient.ListZonesResponse{ 224 | ItemsPerPage: pointerTo(int64(1)), 225 | Message: pointerTo("success"), 226 | TotalItems: pointerTo(int64(2)), 227 | TotalPages: pointerTo(int64(2)), 228 | Zones: &[]stackitdnsclient.Zone{{Id: pointerTo("5678")}}, 229 | } 230 | } 231 | successResponseBytes, err := json.Marshal(zones) 232 | assert.NoError(t, err) 233 | 234 | w.WriteHeader(http.StatusOK) 235 | w.Write(successResponseBytes) 236 | } 237 | } 238 | 239 | func getRrsetsHandlerReecodsPaged(t *testing.T, domain string) http.HandlerFunc { 240 | t.Helper() 241 | 242 | return func(w http.ResponseWriter, r *http.Request) { 243 | w.Header().Set("Content-Type", "application/json") 244 | 245 | rrSets := stackitdnsclient.ListRecordSetsResponse{} 246 | if domain == "1234" { 247 | rrSets = stackitdnsclient.ListRecordSetsResponse{ 248 | ItemsPerPage: pointerTo(int64(1)), 249 | Message: pointerTo("success"), 250 | RrSets: &[]stackitdnsclient.RecordSet{ 251 | { 252 | Name: pointerTo("test.com."), 253 | Type: pointerTo("A"), 254 | Ttl: pointerTo(int64(300)), 255 | Records: &[]stackitdnsclient.Record{ 256 | {Content: pointerTo("1.2.3.4")}, 257 | }, 258 | }, 259 | }, 260 | TotalItems: pointerTo(int64(1)), 261 | TotalPages: pointerTo(int64(1)), 262 | } 263 | } 264 | if domain == "5678" { 265 | rrSets = stackitdnsclient.ListRecordSetsResponse{ 266 | ItemsPerPage: pointerTo(int64(1)), 267 | Message: pointerTo("success"), 268 | RrSets: &[]stackitdnsclient.RecordSet{ 269 | { 270 | Name: pointerTo("test2.com."), 271 | Type: pointerTo("A"), 272 | Ttl: pointerTo(int64(300)), 273 | Records: &[]stackitdnsclient.Record{ 274 | {Content: pointerTo("5.6.7.8")}, 275 | }, 276 | }, 277 | }, 278 | TotalItems: pointerTo(int64(1)), 279 | TotalPages: pointerTo(int64(1)), 280 | } 281 | } 282 | 283 | successResponseBytes, err := json.Marshal(rrSets) 284 | assert.NoError(t, err) 285 | 286 | w.WriteHeader(http.StatusOK) 287 | w.Write(successResponseBytes) 288 | } 289 | } 290 | 291 | func getPagedRecordsServer(t *testing.T) *httptest.Server { 292 | t.Helper() 293 | 294 | mux := http.NewServeMux() 295 | server := httptest.NewServer(mux) 296 | 297 | mux.HandleFunc("/v1/projects/1234/zones", getZonesHandlerRecordsPaged(t)) 298 | mux.HandleFunc("/v1/projects/1234/zones/1234/rrsets", getRrsetsHandlerReecodsPaged(t, "1234")) 299 | mux.HandleFunc("/v1/projects/1234/zones/5678/rrsets", getRrsetsHandlerReecodsPaged(t, "5678")) 300 | 301 | return server 302 | } 303 | 304 | func getZonesResponseRecordsNonPaged(t *testing.T, w http.ResponseWriter) { 305 | t.Helper() 306 | 307 | w.Header().Set("Content-Type", "application/json") 308 | 309 | zones := stackitdnsclient.ListZonesResponse{ 310 | ItemsPerPage: pointerTo(int64(10)), 311 | Message: pointerTo("success"), 312 | TotalItems: pointerTo(int64(2)), 313 | TotalPages: pointerTo(int64(1)), 314 | Zones: &[]stackitdnsclient.Zone{ 315 | {Id: pointerTo("1234"), DnsName: pointerTo("test.com")}, 316 | {Id: pointerTo("5678"), DnsName: pointerTo("test2.com")}, 317 | }, 318 | } 319 | successResponseBytes, err := json.Marshal(zones) 320 | assert.NoError(t, err) 321 | 322 | w.WriteHeader(http.StatusOK) 323 | w.Write(successResponseBytes) 324 | } 325 | 326 | func getRrsetsResponseRecordsNonPaged(t *testing.T, w http.ResponseWriter, domain string) { 327 | t.Helper() 328 | 329 | w.Header().Set("Content-Type", "application/json") 330 | 331 | var rrSets stackitdnsclient.ListRecordSetsResponse 332 | 333 | if domain == "1234" { 334 | rrSets = stackitdnsclient.ListRecordSetsResponse{ 335 | ItemsPerPage: pointerTo(int64(20)), 336 | Message: pointerTo("success"), 337 | RrSets: &[]stackitdnsclient.RecordSet{ 338 | { 339 | Name: pointerTo("test.com."), 340 | Type: pointerTo("A"), 341 | Ttl: pointerTo(int64(300)), 342 | Records: &[]stackitdnsclient.Record{ 343 | {Content: pointerTo("1.2.3.4")}, 344 | }, 345 | Id: pointerTo("1234"), 346 | }, 347 | }, 348 | TotalItems: pointerTo(int64(2)), 349 | TotalPages: pointerTo(int64(1)), 350 | } 351 | } else if domain == "5678" { 352 | rrSets = stackitdnsclient.ListRecordSetsResponse{ 353 | ItemsPerPage: pointerTo(int64(20)), 354 | Message: pointerTo("success"), 355 | RrSets: &[]stackitdnsclient.RecordSet{ 356 | { 357 | Name: pointerTo("test2.com."), 358 | Type: pointerTo("A"), 359 | Ttl: pointerTo(int64(300)), 360 | Records: &[]stackitdnsclient.Record{ 361 | {Content: pointerTo("5.6.7.8")}, 362 | }, 363 | Id: pointerTo("5678"), 364 | }, 365 | }, 366 | TotalItems: pointerTo(int64(2)), 367 | TotalPages: pointerTo(int64(1)), 368 | } 369 | } 370 | 371 | successResponseBytes, err := json.Marshal(rrSets) 372 | assert.NoError(t, err) 373 | 374 | w.WriteHeader(http.StatusOK) 375 | w.Write(successResponseBytes) 376 | } 377 | 378 | func getServerRecords(t *testing.T) *httptest.Server { 379 | t.Helper() 380 | 381 | mux := http.NewServeMux() 382 | server := httptest.NewServer(mux) 383 | 384 | mux.HandleFunc("/v1/projects/1234/zones", func(w http.ResponseWriter, r *http.Request) { 385 | getZonesResponseRecordsNonPaged(t, w) 386 | }) 387 | mux.HandleFunc("/v1/projects/1234/zones/1234/rrsets", func(w http.ResponseWriter, r *http.Request) { 388 | getRrsetsResponseRecordsNonPaged(t, w, "1234") 389 | }) 390 | mux.HandleFunc("/v1/projects/1234/zones/5678/rrsets", func(w http.ResponseWriter, r *http.Request) { 391 | getRrsetsResponseRecordsNonPaged(t, w, "5678") 392 | }) 393 | 394 | return server 395 | } 396 | -------------------------------------------------------------------------------- /internal/stackitprovider/rrset_fetcher.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 8 | "go.uber.org/zap" 9 | "sigs.k8s.io/external-dns/endpoint" 10 | ) 11 | 12 | type rrSetFetcher struct { 13 | apiClient *stackitdnsclient.APIClient 14 | domainFilter endpoint.DomainFilter 15 | projectId string 16 | logger *zap.Logger 17 | } 18 | 19 | func newRRSetFetcher( 20 | apiClient *stackitdnsclient.APIClient, 21 | domainFilter endpoint.DomainFilter, 22 | projectId string, 23 | logger *zap.Logger, 24 | ) *rrSetFetcher { 25 | return &rrSetFetcher{ 26 | apiClient: apiClient, 27 | domainFilter: domainFilter, 28 | projectId: projectId, 29 | logger: logger, 30 | } 31 | } 32 | 33 | // fetchRecords fetches all []stackitdnsclient.RecordSet from STACKIT DNS API for given zone id. 34 | func (r *rrSetFetcher) fetchRecords( 35 | ctx context.Context, 36 | zoneId string, 37 | nameFilter *string, 38 | ) ([]stackitdnsclient.RecordSet, error) { 39 | var result []stackitdnsclient.RecordSet 40 | var pager int32 = 1 41 | 42 | listRequest := r.apiClient.ListRecordSets(ctx, r.projectId, zoneId).Page(pager).PageSize(10000).ActiveEq(true) 43 | 44 | if nameFilter != nil { 45 | listRequest = listRequest.NameLike(*nameFilter) 46 | } 47 | 48 | rrSetResponse, err := listRequest.Execute() 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | result = append(result, *rrSetResponse.RrSets...) 54 | 55 | // if there is more than one page, we need to loop over the other pages and 56 | // issue another API request for each one of them 57 | pager++ 58 | for int64(pager) <= *rrSetResponse.TotalPages { 59 | rrSetResponse, err := listRequest.Page(pager).Execute() 60 | if err != nil { 61 | return nil, err 62 | } 63 | result = append(result, *rrSetResponse.RrSets...) 64 | pager++ 65 | } 66 | 67 | return result, nil 68 | } 69 | 70 | // getRRSetForUpdateDeletion returns the record set to be deleted and the zone it belongs to. 71 | func (r *rrSetFetcher) getRRSetForUpdateDeletion( 72 | ctx context.Context, 73 | change *endpoint.Endpoint, 74 | zones []stackitdnsclient.Zone, 75 | ) (*stackitdnsclient.Zone, *stackitdnsclient.RecordSet, error) { 76 | resultZone, found := findBestMatchingZone(change.DNSName, zones) 77 | if !found { 78 | r.logger.Error( 79 | "record set name contains no zone dns name", 80 | zap.String("name", change.DNSName), 81 | ) 82 | 83 | return nil, nil, fmt.Errorf("record set name contains no zone dns name") 84 | } 85 | 86 | domainRRSets, err := r.fetchRecords(ctx, *resultZone.Id, &change.DNSName) 87 | if err != nil { 88 | return nil, nil, err 89 | } 90 | 91 | resultRRSet, found := findRRSet(change.DNSName, change.RecordType, domainRRSets) 92 | if !found { 93 | r.logger.Info("record not found on record sets", zap.String("name", change.DNSName)) 94 | 95 | return nil, nil, fmt.Errorf("record not found on record sets") 96 | } 97 | 98 | return resultZone, resultRRSet, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/stackitprovider/stackit.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" 5 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 6 | "go.uber.org/zap" 7 | "sigs.k8s.io/external-dns/endpoint" 8 | "sigs.k8s.io/external-dns/provider" 9 | ) 10 | 11 | // StackitDNSProvider implements the DNS stackitprovider for STACKIT DNS. 12 | type StackitDNSProvider struct { 13 | provider.BaseProvider 14 | projectId string 15 | domainFilter endpoint.DomainFilter 16 | dryRun bool 17 | workers int 18 | logger *zap.Logger 19 | apiClient *stackitdnsclient.APIClient 20 | zoneFetcherClient *zoneFetcher 21 | rrSetFetcherClient *rrSetFetcher 22 | } 23 | 24 | // NewStackitDNSProvider creates a new STACKIT DNS stackitprovider. 25 | func NewStackitDNSProvider( 26 | logger *zap.Logger, 27 | providerConfig Config, 28 | stackitConfig ...stackitconfig.ConfigurationOption, 29 | ) (*StackitDNSProvider, error) { 30 | apiClient, err := stackitdnsclient.NewAPIClient(stackitConfig...) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | provider := &StackitDNSProvider{ 36 | apiClient: apiClient, 37 | domainFilter: providerConfig.DomainFilter, 38 | dryRun: providerConfig.DryRun, 39 | projectId: providerConfig.ProjectId, 40 | workers: providerConfig.Workers, 41 | logger: logger, 42 | zoneFetcherClient: newZoneFetcher(apiClient, providerConfig.DomainFilter, providerConfig.ProjectId), 43 | rrSetFetcherClient: newRRSetFetcher(apiClient, providerConfig.DomainFilter, providerConfig.ProjectId, logger), 44 | } 45 | 46 | return provider, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/stackitprovider/stackit_test.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | -------------------------------------------------------------------------------- /internal/stackitprovider/zone_fetcher.go: -------------------------------------------------------------------------------- 1 | package stackitprovider 2 | 3 | import ( 4 | "context" 5 | 6 | stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" 7 | "sigs.k8s.io/external-dns/endpoint" 8 | ) 9 | 10 | type zoneFetcher struct { 11 | apiClient *stackitdnsclient.APIClient 12 | domainFilter endpoint.DomainFilter 13 | projectId string 14 | } 15 | 16 | func newZoneFetcher( 17 | apiClient *stackitdnsclient.APIClient, 18 | domainFilter endpoint.DomainFilter, 19 | projectId string, 20 | ) *zoneFetcher { 21 | return &zoneFetcher{ 22 | apiClient: apiClient, 23 | domainFilter: domainFilter, 24 | projectId: projectId, 25 | } 26 | } 27 | 28 | // zones returns filtered list of stackitdnsclient.Zone if filter is set. 29 | func (z *zoneFetcher) zones(ctx context.Context) ([]stackitdnsclient.Zone, error) { 30 | if len(z.domainFilter.Filters) == 0 { 31 | // no filters, return all zones 32 | listRequest := z.apiClient.ListZones(ctx, z.projectId).ActiveEq(true) 33 | zones, err := z.fetchZones(listRequest) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return zones, nil 39 | } 40 | 41 | var result []stackitdnsclient.Zone 42 | // send one request per filter 43 | for _, filter := range z.domainFilter.Filters { 44 | listRequest := z.apiClient.ListZones(ctx, z.projectId).ActiveEq(true).DnsNameLike(filter) 45 | zones, err := z.fetchZones(listRequest) 46 | if err != nil { 47 | return nil, err 48 | } 49 | result = append(result, zones...) 50 | } 51 | 52 | return result, nil 53 | } 54 | 55 | // fetchZones fetches all []stackitdnsclient.Zone from STACKIT DNS API. 56 | func (z *zoneFetcher) fetchZones( 57 | listRequest stackitdnsclient.ApiListZonesRequest, 58 | ) ([]stackitdnsclient.Zone, error) { 59 | var result []stackitdnsclient.Zone 60 | var pager int32 = 1 61 | 62 | listRequest = listRequest.Page(1).PageSize(10000) 63 | 64 | zoneResponse, err := listRequest.Execute() 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | result = append(result, *zoneResponse.Zones...) 70 | 71 | // if there is more than one page, we need to loop over the other pages and 72 | // issue another API request for each one of them 73 | pager++ 74 | for int64(pager) <= *zoneResponse.TotalPages { 75 | zoneResponse, err := listRequest.Page(pager).Execute() 76 | if err != nil { 77 | return nil, err 78 | } 79 | result = append(result, *zoneResponse.Zones...) 80 | pager++ 81 | } 82 | 83 | return result, nil 84 | } 85 | -------------------------------------------------------------------------------- /licenses/licenses-ignore-list.txt: -------------------------------------------------------------------------------- 1 | github.com/cespare/xxhash/v2,github.com/modern-go/reflect2,golang.org/x/sys/unix 2 | -------------------------------------------------------------------------------- /pkg/api/adjust_endpoints.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "go.uber.org/zap" 8 | "sigs.k8s.io/external-dns/endpoint" 9 | ) 10 | 11 | func (w webhook) AdjustEndpoints(ctx *fiber.Ctx) error { 12 | var pve []*endpoint.Endpoint 13 | err := ctx.BodyParser(&pve) 14 | if err != nil { 15 | w.logger.Error("Error parsing body", zap.String(logFieldError, err.Error())) 16 | ctx.Response().Header.Set(contentTypeHeader, contentTypePlaintext) 17 | 18 | return ctx.Status(fiber.StatusBadRequest).SendString(err.Error()) 19 | } 20 | 21 | pve = w.provider.AdjustEndpoints(pve) 22 | 23 | w.logger.Debug("adjusted endpoints", zap.String("endpoints", fmt.Sprintf("%v", pve))) 24 | 25 | ctx.Set(varyHeader, contentTypeHeader) 26 | 27 | return ctx.JSON(pve) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/api/adjust_endpoints_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/goccy/go-json" 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/stretchr/testify/assert" 12 | "go.uber.org/mock/gomock" 13 | "go.uber.org/zap" 14 | "sigs.k8s.io/external-dns/endpoint" 15 | 16 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 17 | mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" 18 | ) 19 | 20 | func TestWebhook_AdjustEndpoints(t *testing.T) { 21 | t.Parallel() 22 | 23 | ctrl := gomock.NewController(t) 24 | t.Cleanup(ctrl.Finish) 25 | 26 | mockLogger := zap.NewNop() 27 | mockProvider := mock_provider.NewMockProvider(ctrl) 28 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 29 | 30 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 31 | 32 | t.Run("Test provider returns records successfully", func(t *testing.T) { 33 | t.Parallel() 34 | 35 | endpoints := []*endpoint.Endpoint{ 36 | { 37 | DNSName: "test.com", 38 | RecordType: "A", 39 | }, 40 | } 41 | mockProvider.EXPECT().AdjustEndpoints(endpoints).Return(endpoints).Times(1) 42 | 43 | body, err := json.Marshal(endpoints) 44 | assert.NoError(t, err) 45 | 46 | req := httptest.NewRequest(http.MethodPost, "/adjustendpoints", bytes.NewReader(body)) 47 | req.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 48 | 49 | resp, err := app.Test(req) 50 | assert.NoError(t, err) 51 | assert.Equal(t, http.StatusOK, resp.StatusCode) 52 | }) 53 | 54 | t.Run("Test invalid data send by client", func(t *testing.T) { 55 | t.Parallel() 56 | 57 | reqBad := httptest.NewRequest(http.MethodPost, "/adjustendpoints", bytes.NewReader([]byte(`{"bad":"request"}`))) 58 | reqBad.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 59 | 60 | respBad, err := app.Test(reqBad) 61 | assert.NoError(t, err) 62 | assert.Equal(t, http.StatusBadRequest, respBad.StatusCode) 63 | }) 64 | 65 | t.Run("Client send invalid JSON", func(t *testing.T) { 66 | t.Parallel() 67 | 68 | reqBad := httptest.NewRequest(http.MethodPost, "/adjustendpoints", bytes.NewReader([]byte(`{"wrong:"request"`))) 69 | reqBad.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 70 | 71 | respBad, err := app.Test(reqBad) 72 | assert.NoError(t, err) 73 | assert.Equal(t, http.StatusBadRequest, respBad.StatusCode) 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | json "github.com/goccy/go-json" 13 | "github.com/gofiber/fiber/v2" 14 | "github.com/gofiber/fiber/v2/middleware/helmet" 15 | fiberlogger "github.com/gofiber/fiber/v2/middleware/logger" 16 | "github.com/gofiber/fiber/v2/middleware/pprof" 17 | fiberrecover "github.com/gofiber/fiber/v2/middleware/recover" 18 | "go.uber.org/zap" 19 | "sigs.k8s.io/external-dns/provider" 20 | 21 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" 22 | ) 23 | 24 | type Api interface { 25 | Listen(port string) error 26 | Test(req *http.Request, msTimeout ...int) (resp *http.Response, err error) 27 | } 28 | 29 | type api struct { 30 | logger *zap.Logger 31 | app *fiber.App 32 | } 33 | 34 | func (a api) Test(req *http.Request, msTimeout ...int) (resp *http.Response, err error) { 35 | return a.app.Test(req, msTimeout...) 36 | } 37 | 38 | func (a api) Listen(port string) error { 39 | go func() { 40 | err := a.app.Listen(fmt.Sprintf(":%s", port)) 41 | if err != nil { 42 | a.logger.Fatal("Error starting the server", zap.String(logFieldError, err.Error())) 43 | } 44 | }() 45 | 46 | sigCh := make(chan os.Signal, 1) 47 | signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 48 | sig := <-sigCh 49 | 50 | a.logger.Info( 51 | "shutting down server due to received signal", 52 | zap.String("signal", sig.String()), 53 | ) 54 | 55 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 56 | err := a.app.ShutdownWithContext(ctx) 57 | if err != nil { 58 | a.logger.Error("error shutting down server", zap.String("err", err.Error())) 59 | } 60 | 61 | cancel() 62 | 63 | return err 64 | } 65 | 66 | //go:generate mockgen -destination=./mock/api.go -source=./api.go Provider 67 | type Provider interface { 68 | provider.Provider 69 | } 70 | 71 | func New(logger *zap.Logger, middlewareCollector metrics.HttpApiMetrics, provider provider.Provider) Api { 72 | app := fiber.New(fiber.Config{ 73 | DisableStartupMessage: true, 74 | JSONEncoder: json.Marshal, 75 | JSONDecoder: json.Unmarshal, 76 | }) 77 | 78 | registerAt(app, "/metrics") 79 | app.Get("/healthz", Health) 80 | 81 | app.Use(NewMetricsMiddleware(middlewareCollector)) 82 | app.Use(fiberlogger.New()) 83 | app.Use(pprof.New(pprof.Config{Prefix: "/pprof"})) 84 | app.Use(fiberrecover.New()) 85 | app.Use(helmet.New()) 86 | 87 | webhookRoutes := webhook{ 88 | provider: provider, 89 | logger: logger, 90 | } 91 | 92 | app.Get("/records", webhookRoutes.Records) 93 | app.Get("/", webhookRoutes.GetDomainFilter) 94 | app.Post("/records", webhookRoutes.ApplyChanges) 95 | app.Post("/adjustendpoints", webhookRoutes.AdjustEndpoints) 96 | 97 | return &api{ 98 | logger: logger, 99 | app: app, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "syscall" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "go.uber.org/mock/gomock" 13 | "go.uber.org/zap" 14 | 15 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 16 | mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" 17 | ) 18 | 19 | func TestApi(t *testing.T) { 20 | t.Parallel() 21 | 22 | ctrl := gomock.NewController(t) 23 | t.Cleanup(ctrl.Finish) 24 | 25 | mockLogger := zap.NewNop() 26 | mockProvider := mock_provider.NewMockProvider(ctrl) 27 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 28 | 29 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 30 | 31 | t.Run("Test", func(t *testing.T) { 32 | t.Parallel() 33 | 34 | req := httptest.NewRequest(http.MethodGet, "/healthz", nil) 35 | 36 | resp, err := app.Test(req) 37 | assert.NoError(t, err) 38 | assert.Equal(t, http.StatusOK, resp.StatusCode) 39 | }) 40 | 41 | t.Run("Listen", func(t *testing.T) { 42 | t.Parallel() 43 | 44 | go func() { 45 | err := app.Listen("5000") 46 | assert.NoError(t, err) 47 | }() 48 | 49 | time.Sleep(time.Second) // Allow server to start 50 | 51 | // Simulate SIGTERM signal to trigger shutdown 52 | process, _ := os.FindProcess(os.Getpid()) 53 | err := process.Signal(syscall.SIGTERM) 54 | assert.NoError(t, err) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/api/apply_changes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "go.uber.org/zap" 8 | "sigs.k8s.io/external-dns/plan" 9 | ) 10 | 11 | func (w webhook) ApplyChanges(ctx *fiber.Ctx) error { 12 | var changes plan.Changes 13 | err := ctx.BodyParser(&changes) 14 | if err != nil { 15 | w.logger.Error("Error parsing body", zap.String(logFieldError, err.Error())) 16 | ctx.Response().Header.Set(contentTypeHeader, contentTypePlaintext) 17 | 18 | return ctx.Status(fiber.StatusBadRequest).SendString(err.Error()) 19 | } 20 | 21 | w.logger.Debug( 22 | "requesting apply changes", 23 | zap.String("create", fmt.Sprintf("%v", changes.Create)), 24 | zap.String("delete", fmt.Sprintf("%v", changes.Delete)), 25 | zap.String("updateNew", fmt.Sprintf("%v", changes.UpdateNew)), 26 | zap.String("updateOld", fmt.Sprintf("%v", changes.UpdateOld)), 27 | zap.String("updateNew", fmt.Sprintf("%v", changes.UpdateNew)), 28 | ) 29 | 30 | err = w.provider.ApplyChanges(ctx.UserContext(), &changes) 31 | if err != nil { 32 | w.logger.Error("Error applying changes", zap.String(logFieldError, err.Error())) 33 | ctx.Response().Header.Set(contentTypeHeader, contentTypePlaintext) 34 | 35 | return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) 36 | } 37 | 38 | ctx.Status(fiber.StatusNoContent) 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/api/apply_changes_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/stretchr/testify/assert" 13 | "go.uber.org/mock/gomock" 14 | "go.uber.org/zap" 15 | "sigs.k8s.io/external-dns/endpoint" 16 | "sigs.k8s.io/external-dns/plan" 17 | 18 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 19 | mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" 20 | ) 21 | 22 | func TestWebhook_ApplyChanges(t *testing.T) { 23 | t.Parallel() 24 | 25 | ctrl := gomock.NewController(t) 26 | t.Cleanup(ctrl.Finish) 27 | 28 | changes := getValidPlanChanges() 29 | body, err := json.Marshal(changes) 30 | assert.NoError(t, err) 31 | 32 | t.Run("Provider returns records successfully", func(t *testing.T) { 33 | t.Parallel() 34 | 35 | mockLogger := zap.NewNop() 36 | mockProvider := mock_provider.NewMockProvider(ctrl) 37 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 38 | 39 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 40 | mockProvider.EXPECT().ApplyChanges(gomock.Any(), changes).Return(nil).Times(1) 41 | 42 | req := httptest.NewRequest(http.MethodPost, "/records", bytes.NewReader(body)) 43 | req.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 44 | 45 | resp, err := app.Test(req, -1) 46 | assert.NoError(t, err) 47 | assert.Equal(t, http.StatusNoContent, resp.StatusCode) 48 | }) 49 | 50 | t.Run("Invalid data send by client", func(t *testing.T) { 51 | t.Parallel() 52 | 53 | mockLogger := zap.NewNop() 54 | mockProvider := mock_provider.NewMockProvider(ctrl) 55 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 56 | 57 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 58 | mockProvider.EXPECT().ApplyChanges(gomock.Any(), gomock.Any()).Return(nil).Times(1) 59 | reqBad := httptest.NewRequest(http.MethodPost, "/records", bytes.NewReader([]byte(`{"bad":"request"}`))) 60 | reqBad.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 61 | 62 | respBad, err := app.Test(reqBad) 63 | assert.NoError(t, err) 64 | assert.Equal(t, http.StatusNoContent, respBad.StatusCode) 65 | }) 66 | 67 | t.Run("Provider returns error", func(t *testing.T) { 68 | t.Parallel() 69 | 70 | mockLogger := zap.NewNop() 71 | mockProvider := mock_provider.NewMockProvider(ctrl) 72 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 73 | 74 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 75 | mockProvider.EXPECT().ApplyChanges(gomock.Any(), changes).Return(fmt.Errorf("test error")).Times(1) 76 | 77 | reqFail := httptest.NewRequest(http.MethodPost, "/records", bytes.NewReader(body)) 78 | reqFail.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 79 | 80 | respFail, err := app.Test(reqFail) 81 | assert.NoError(t, err) 82 | assert.Equal(t, http.StatusInternalServerError, respFail.StatusCode) 83 | }) 84 | 85 | t.Run("Client send invalid JSON", func(t *testing.T) { 86 | t.Parallel() 87 | 88 | mockLogger := zap.NewNop() 89 | mockProvider := mock_provider.NewMockProvider(ctrl) 90 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 91 | 92 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 93 | 94 | reqBad := httptest.NewRequest(http.MethodPost, "/records", bytes.NewReader([]byte(`{"wrong:"request"`))) 95 | reqBad.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 96 | 97 | respBad, err := app.Test(reqBad) 98 | assert.NoError(t, err) 99 | assert.Equal(t, http.StatusBadRequest, respBad.StatusCode) 100 | }) 101 | } 102 | 103 | func getValidPlanChanges() *plan.Changes { 104 | changes := &plan.Changes{ 105 | Create: []*endpoint.Endpoint{ 106 | { 107 | DNSName: "test.create.com", 108 | RecordType: "A", 109 | }, 110 | }, 111 | Delete: []*endpoint.Endpoint{ 112 | { 113 | DNSName: "test.delete.com", 114 | RecordType: "A", 115 | }, 116 | }, 117 | UpdateOld: []*endpoint.Endpoint{ 118 | { 119 | DNSName: "test.updateold.com", 120 | RecordType: "A", 121 | }, 122 | }, 123 | UpdateNew: []*endpoint.Endpoint{ 124 | { 125 | DNSName: "test.updatenew.com", 126 | RecordType: "A", 127 | }, 128 | }, 129 | } 130 | 131 | return changes 132 | } 133 | -------------------------------------------------------------------------------- /pkg/api/domain_filter.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/goccy/go-json" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | func (w webhook) GetDomainFilter(ctx *fiber.Ctx) error { 9 | data, err := json.Marshal(w.provider.GetDomainFilter()) 10 | if err != nil { 11 | return err 12 | } 13 | 14 | ctx.Set(varyHeader, contentTypeHeader) 15 | ctx.Set(contentTypeHeader, mediaTypeFormat) 16 | 17 | return ctx.Send(data) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/api/domain_filter_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/goccy/go-json" 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/stretchr/testify/assert" 12 | "go.uber.org/mock/gomock" 13 | "go.uber.org/zap" 14 | "sigs.k8s.io/external-dns/endpoint" 15 | 16 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 17 | mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" 18 | ) 19 | 20 | func TestWebhook_DomainFilter(t *testing.T) { 21 | t.Parallel() 22 | 23 | ctrl := gomock.NewController(t) 24 | t.Cleanup(ctrl.Finish) 25 | 26 | mockLogger := zap.NewNop() 27 | mockProvider := mock_provider.NewMockProvider(ctrl) 28 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 29 | expectedDomainFilter := endpoint.DomainFilter{Filters: []string{"test"}} 30 | 31 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 32 | mockProvider.EXPECT(). 33 | GetDomainFilter(). 34 | Return(expectedDomainFilter). 35 | Times(1) 36 | 37 | req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(nil)) 38 | req.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 39 | 40 | resp, err := app.Test(req) 41 | assert.NoError(t, err) 42 | assert.Equal(t, http.StatusOK, resp.StatusCode) 43 | 44 | var domainFilterResponse endpoint.DomainFilter 45 | err = json.NewDecoder(resp.Body).Decode(&domainFilterResponse) 46 | assert.NoError(t, err) 47 | assert.Equal(t, expectedDomainFilter, domainFilterResponse) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | // Health godoc 6 | // @Summary Health route 7 | // @Description Health route 8 | // @Accept json 9 | // @Produce json 10 | // @Success 200 {object} Message 11 | // @Router /v1/healthz [get] 12 | // @Tags health 13 | // get route. 14 | func Health(c *fiber.Ctx) error { 15 | c.Status(fiber.StatusOK) 16 | 17 | return c.JSON(Message{ 18 | Message: "healthy", 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/api/health_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/stretchr/testify/assert" 10 | "go.uber.org/mock/gomock" 11 | "go.uber.org/zap" 12 | 13 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 14 | mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" 15 | ) 16 | 17 | func TestHealth(t *testing.T) { 18 | t.Parallel() 19 | 20 | ctrl := gomock.NewController(t) 21 | 22 | mockLogger := zap.NewNop() 23 | mockProvider := mock_provider.NewMockProvider(ctrl) 24 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 25 | 26 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 27 | 28 | req := httptest.NewRequest(http.MethodGet, "/healthz", nil) 29 | req.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) 30 | 31 | resp, err := app.Test(req, -1) 32 | assert.NoError(t, err) 33 | assert.Equal(t, http.StatusOK, resp.StatusCode) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/api/metrics.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/gofiber/adaptor/v2" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | 11 | metrics_collector "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" 12 | ) 13 | 14 | // registerAt registers the metrics endpoint. 15 | func registerAt(app *fiber.App, path string) { 16 | app.Get(path, adaptor.HTTPHandler(promhttp.Handler())) 17 | } 18 | 19 | func NewMetricsMiddleware(collector metrics_collector.HttpApiMetrics) fiber.Handler { 20 | return func(c *fiber.Ctx) error { 21 | started := time.Now() 22 | 23 | // Continue with the chain of middleware and handlers 24 | err := c.Next() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | method := strings.Clone(c.Method()) 30 | path := strings.Clone(c.Path()) 31 | status := c.Response().StatusCode() 32 | 33 | collector.CollectRequest(method, path, status) 34 | collector.CollectTotalRequests() 35 | collectHttpStatusErrors(status, collector) 36 | collector.CollectRequestResponseSize(method, path, float64(len(c.Response().Body()))) 37 | collector.CollectRequestDuration(method, path, time.Since(started).Seconds()) 38 | 39 | return nil 40 | } 41 | } 42 | 43 | func collectHttpStatusErrors(status int, collector metrics_collector.HttpApiMetrics) { 44 | if status >= 400 && status < 500 { 45 | collector.Collect400TotalRequests() 46 | } else if status >= 500 && status < 600 { 47 | collector.Collect500TotalRequests() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/api/metrics_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/stretchr/testify/assert" 10 | "go.uber.org/mock/gomock" 11 | 12 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 13 | metrics_collector "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" 14 | mock_metrics_collector "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics/mock" 15 | ) 16 | 17 | func TestMetricsMiddleware(t *testing.T) { 18 | t.Parallel() 19 | 20 | mockCtrl := gomock.NewController(t) 21 | defer mockCtrl.Finish() 22 | 23 | metricsCollector := mock_metrics_collector.NewMockHttpApiMetrics(mockCtrl) 24 | 25 | // Expectations 26 | method := http.MethodGet 27 | path := "/" 28 | statusCode := http.StatusOK 29 | contentLength := float64(4) 30 | 31 | metricsCollector.EXPECT().CollectRequest(method, path, statusCode).Times(1) 32 | metricsCollector.EXPECT().CollectTotalRequests().Times(1) 33 | metricsCollector.EXPECT().CollectRequestResponseSize(method, path, contentLength).Times(1) 34 | metricsCollector.EXPECT().CollectRequestDuration(method, path, gomock.Any()).Times(1) 35 | 36 | app := setupTestMetrics(metricsCollector) 37 | 38 | req := httptest.NewRequest(http.MethodGet, "/", nil) 39 | resp, err := app.Test(req) 40 | 41 | assert.NoError(t, err) 42 | assert.Equal(t, http.StatusOK, resp.StatusCode) 43 | } 44 | 45 | func setupTestMetrics(collector metrics_collector.HttpApiMetrics) *fiber.App { 46 | app := fiber.New() 47 | app.Use(api.NewMetricsMiddleware(collector)) 48 | app.Get("/", func(c *fiber.Ctx) error { 49 | return c.SendString("test") 50 | }) 51 | 52 | return app 53 | } 54 | 55 | func getTestMockMetricsCollector(ctrl *gomock.Controller) metrics_collector.HttpApiMetrics { 56 | metricsCollector := mock_metrics_collector.NewMockHttpApiMetrics(ctrl) 57 | 58 | metricsCollector.EXPECT().CollectRequest(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() 59 | metricsCollector.EXPECT().CollectTotalRequests().AnyTimes() 60 | metricsCollector.EXPECT().CollectRequestResponseSize(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() 61 | metricsCollector.EXPECT().CollectRequestDuration(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() 62 | metricsCollector.EXPECT().Collect400TotalRequests().AnyTimes() 63 | metricsCollector.EXPECT().Collect500TotalRequests().AnyTimes() 64 | 65 | return metricsCollector 66 | } 67 | -------------------------------------------------------------------------------- /pkg/api/mock/api.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./api.go 3 | 4 | // Package mock_api is a generated GoMock package. 5 | package mock_api 6 | 7 | import ( 8 | context "context" 9 | http "net/http" 10 | reflect "reflect" 11 | 12 | gomock "go.uber.org/mock/gomock" 13 | endpoint "sigs.k8s.io/external-dns/endpoint" 14 | plan "sigs.k8s.io/external-dns/plan" 15 | ) 16 | 17 | // MockApi is a mock of Api interface. 18 | type MockApi struct { 19 | ctrl *gomock.Controller 20 | recorder *MockApiMockRecorder 21 | } 22 | 23 | // MockApiMockRecorder is the mock recorder for MockApi. 24 | type MockApiMockRecorder struct { 25 | mock *MockApi 26 | } 27 | 28 | // NewMockApi creates a new mock instance. 29 | func NewMockApi(ctrl *gomock.Controller) *MockApi { 30 | mock := &MockApi{ctrl: ctrl} 31 | mock.recorder = &MockApiMockRecorder{mock} 32 | return mock 33 | } 34 | 35 | // EXPECT returns an object that allows the caller to indicate expected use. 36 | func (m *MockApi) EXPECT() *MockApiMockRecorder { 37 | return m.recorder 38 | } 39 | 40 | // Listen mocks base method. 41 | func (m *MockApi) Listen(port string) error { 42 | m.ctrl.T.Helper() 43 | ret := m.ctrl.Call(m, "Listen", port) 44 | ret0, _ := ret[0].(error) 45 | return ret0 46 | } 47 | 48 | // Listen indicates an expected call of Listen. 49 | func (mr *MockApiMockRecorder) Listen(port interface{}) *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Listen", reflect.TypeOf((*MockApi)(nil).Listen), port) 52 | } 53 | 54 | // Test mocks base method. 55 | func (m *MockApi) Test(req *http.Request, msTimeout ...int) (*http.Response, error) { 56 | m.ctrl.T.Helper() 57 | varargs := []interface{}{req} 58 | for _, a := range msTimeout { 59 | varargs = append(varargs, a) 60 | } 61 | ret := m.ctrl.Call(m, "Test", varargs...) 62 | ret0, _ := ret[0].(*http.Response) 63 | ret1, _ := ret[1].(error) 64 | return ret0, ret1 65 | } 66 | 67 | // Test indicates an expected call of Test. 68 | func (mr *MockApiMockRecorder) Test(req interface{}, msTimeout ...interface{}) *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | varargs := append([]interface{}{req}, msTimeout...) 71 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Test", reflect.TypeOf((*MockApi)(nil).Test), varargs...) 72 | } 73 | 74 | // MockProvider is a mock of Provider interface. 75 | type MockProvider struct { 76 | ctrl *gomock.Controller 77 | recorder *MockProviderMockRecorder 78 | } 79 | 80 | // MockProviderMockRecorder is the mock recorder for MockProvider. 81 | type MockProviderMockRecorder struct { 82 | mock *MockProvider 83 | } 84 | 85 | // NewMockProvider creates a new mock instance. 86 | func NewMockProvider(ctrl *gomock.Controller) *MockProvider { 87 | mock := &MockProvider{ctrl: ctrl} 88 | mock.recorder = &MockProviderMockRecorder{mock} 89 | return mock 90 | } 91 | 92 | // EXPECT returns an object that allows the caller to indicate expected use. 93 | func (m *MockProvider) EXPECT() *MockProviderMockRecorder { 94 | return m.recorder 95 | } 96 | 97 | // AdjustEndpoints mocks base method. 98 | func (m *MockProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { 99 | m.ctrl.T.Helper() 100 | ret := m.ctrl.Call(m, "AdjustEndpoints", endpoints) 101 | ret0, _ := ret[0].([]*endpoint.Endpoint) 102 | return ret0 103 | } 104 | 105 | // AdjustEndpoints indicates an expected call of AdjustEndpoints. 106 | func (mr *MockProviderMockRecorder) AdjustEndpoints(endpoints interface{}) *gomock.Call { 107 | mr.mock.ctrl.T.Helper() 108 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdjustEndpoints", reflect.TypeOf((*MockProvider)(nil).AdjustEndpoints), endpoints) 109 | } 110 | 111 | // ApplyChanges mocks base method. 112 | func (m *MockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 113 | m.ctrl.T.Helper() 114 | ret := m.ctrl.Call(m, "ApplyChanges", ctx, changes) 115 | ret0, _ := ret[0].(error) 116 | return ret0 117 | } 118 | 119 | // ApplyChanges indicates an expected call of ApplyChanges. 120 | func (mr *MockProviderMockRecorder) ApplyChanges(ctx, changes interface{}) *gomock.Call { 121 | mr.mock.ctrl.T.Helper() 122 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyChanges", reflect.TypeOf((*MockProvider)(nil).ApplyChanges), ctx, changes) 123 | } 124 | 125 | // GetDomainFilter mocks base method. 126 | func (m *MockProvider) GetDomainFilter() endpoint.DomainFilter { 127 | m.ctrl.T.Helper() 128 | ret := m.ctrl.Call(m, "GetDomainFilter") 129 | ret0, _ := ret[0].(endpoint.DomainFilter) 130 | return ret0 131 | } 132 | 133 | // GetDomainFilter indicates an expected call of GetDomainFilter. 134 | func (mr *MockProviderMockRecorder) GetDomainFilter() *gomock.Call { 135 | mr.mock.ctrl.T.Helper() 136 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDomainFilter", reflect.TypeOf((*MockProvider)(nil).GetDomainFilter)) 137 | } 138 | 139 | // Records mocks base method. 140 | func (m *MockProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 141 | m.ctrl.T.Helper() 142 | ret := m.ctrl.Call(m, "Records", ctx) 143 | ret0, _ := ret[0].([]*endpoint.Endpoint) 144 | ret1, _ := ret[1].(error) 145 | return ret0, ret1 146 | } 147 | 148 | // Records indicates an expected call of Records. 149 | func (mr *MockProviderMockRecorder) Records(ctx interface{}) *gomock.Call { 150 | mr.mock.ctrl.T.Helper() 151 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Records", reflect.TypeOf((*MockProvider)(nil).Records), ctx) 152 | } 153 | -------------------------------------------------------------------------------- /pkg/api/models.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | const ( 4 | mediaTypeFormat = "application/external.dns.webhook+json;version=1" 5 | contentTypeHeader = "Content-Type" 6 | contentTypePlaintext = "text/plain" 7 | varyHeader = "Vary" 8 | logFieldError = "err" 9 | ) 10 | 11 | type Message struct { 12 | Message string `json:"message"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/api/records.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | func (w webhook) Records(ctx *fiber.Ctx) error { 11 | records, err := w.provider.Records(ctx.UserContext()) 12 | if err != nil { 13 | w.logger.Error("Error getting records", zap.String(logFieldError, err.Error())) 14 | 15 | return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) 16 | } 17 | 18 | w.logger.Debug("returning records", zap.String("records", fmt.Sprintf("%v", records))) 19 | 20 | ctx.Response().Header.Set(varyHeader, contentTypeHeader) 21 | 22 | return ctx.JSON(records) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/api/records_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/goccy/go-json" 10 | "github.com/stretchr/testify/assert" 11 | "go.uber.org/mock/gomock" 12 | "go.uber.org/zap" 13 | "sigs.k8s.io/external-dns/endpoint" 14 | 15 | "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" 16 | mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" 17 | ) 18 | 19 | func TestWebhook_Records(t *testing.T) { 20 | t.Parallel() 21 | 22 | ctrl := gomock.NewController(t) 23 | t.Cleanup(ctrl.Finish) 24 | 25 | expectedRecords := []*endpoint.Endpoint{ 26 | { 27 | DNSName: "test.endpoint", 28 | RecordType: "A", 29 | Targets: []string{"127.0.0.1"}, 30 | }, 31 | } 32 | 33 | t.Run("Provider returns records successfully", func(t *testing.T) { 34 | t.Parallel() 35 | 36 | mockLogger := zap.NewNop() 37 | mockProvider := mock_provider.NewMockProvider(ctrl) 38 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 39 | 40 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 41 | mockProvider.EXPECT().Records(gomock.Any()).Return(expectedRecords, nil).Times(1) 42 | 43 | req := httptest.NewRequest(http.MethodGet, "/records", nil) 44 | 45 | resp, err := app.Test(req) 46 | assert.NoError(t, err) 47 | assert.Equal(t, http.StatusOK, resp.StatusCode) 48 | 49 | var respRecords []*endpoint.Endpoint 50 | err = json.NewDecoder(resp.Body).Decode(&respRecords) 51 | assert.NoError(t, err) 52 | assert.Equal(t, expectedRecords, respRecords) 53 | }) 54 | 55 | t.Run("Provider returns no records", func(t *testing.T) { 56 | t.Parallel() 57 | 58 | mockLogger := zap.NewNop() 59 | mockProvider := mock_provider.NewMockProvider(ctrl) 60 | mockMetricsCollector := getTestMockMetricsCollector(ctrl) 61 | 62 | app := api.New(mockLogger, mockMetricsCollector, mockProvider) 63 | mockProvider.EXPECT().Records(gomock.Any()).Return(nil, fmt.Errorf("error")).Times(1) 64 | 65 | reqErr := httptest.NewRequest(http.MethodGet, "/records", nil) 66 | 67 | respErr, err := app.Test(reqErr) 68 | assert.NoError(t, err) 69 | assert.Equal(t, http.StatusInternalServerError, respErr.StatusCode) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/api/webhook.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "sigs.k8s.io/external-dns/provider" 6 | ) 7 | 8 | type webhook struct { 9 | provider provider.Provider 10 | logger *zap.Logger 11 | } 12 | -------------------------------------------------------------------------------- /pkg/metrics/http_middleware.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promauto" 8 | ) 9 | 10 | // HttpApiMetrics is an interface that defines the methods that can be used to collect metrics. 11 | // 12 | //go:generate mockgen -destination=./mock/http_middleware.go -source=./http_middleware.go HttpApiMetrics 13 | type HttpApiMetrics interface { 14 | // CollectTotalRequests increment the total requests for the api 15 | CollectTotalRequests() 16 | // Collect400TotalRequests increment the total requests for the api with 400 status code 17 | Collect400TotalRequests() 18 | // Collect500TotalRequests increment the total requests for the api with 500 status code 19 | Collect500TotalRequests() 20 | // CollectRequest increment the total requests for the api with the given method and path and status code 21 | CollectRequest(method, path string, statusCode int) 22 | // CollectRequestContentLength increment the total content length for the api with the given method and path 23 | CollectRequestContentLength(method, path string, contentLength float64) 24 | // CollectRequestResponseSize increment the total response size for the api with the given method and path 25 | CollectRequestResponseSize(method, path string, contentLength float64) 26 | // CollectRequestDuration observe the histogram of the duration of the requests for the api with the given method and path 27 | CollectRequestDuration(method, path string, duration float64) 28 | } 29 | 30 | // httpApiMetrics is a struct that implements the HttpApiMetrics interface. 31 | type httpApiMetrics struct { 32 | httpRequestsTotal prometheus.Counter 33 | http500Total prometheus.Counter 34 | http400Total prometheus.Counter 35 | httpRequest *prometheus.CounterVec 36 | httpRequestContentLength *prometheus.CounterVec 37 | httpRequestResponseSize *prometheus.CounterVec 38 | httpRequestDuration *prometheus.HistogramVec 39 | } 40 | 41 | // CollectTotalRequests increment the total requests for the api. 42 | func (h *httpApiMetrics) CollectTotalRequests() { 43 | h.httpRequestsTotal.Inc() 44 | } 45 | 46 | // Collect400TotalRequests increment the total requests for the api with 400 status code. 47 | func (h *httpApiMetrics) Collect400TotalRequests() { 48 | h.http400Total.Inc() 49 | } 50 | 51 | // Collect500TotalRequests increment the total requests for the api with 500 status code. 52 | func (h *httpApiMetrics) Collect500TotalRequests() { 53 | h.http500Total.Inc() 54 | } 55 | 56 | // CollectRequest increment the total requests for the api with the given method and path and status code. 57 | func (h *httpApiMetrics) CollectRequest(method, path string, statusCode int) { 58 | status := strconv.Itoa(statusCode) 59 | h.httpRequest.WithLabelValues(method, path, status).Inc() 60 | } 61 | 62 | // CollectRequestContentLength increment the total content length for the api with the given method and path. 63 | func (h *httpApiMetrics) CollectRequestContentLength(method, path string, contentLength float64) { 64 | h.httpRequestContentLength.WithLabelValues(method, path).Add(contentLength) 65 | } 66 | 67 | // CollectRequestResponseSize increment the total response size for the api with the given method and path. 68 | func (h *httpApiMetrics) CollectRequestResponseSize(method, path string, contentLength float64) { 69 | h.httpRequestResponseSize.WithLabelValues(method, path).Add(contentLength) 70 | } 71 | 72 | // CollectRequestDuration observe the histogram of the duration of the requests for the api with the given method and path. 73 | func (h *httpApiMetrics) CollectRequestDuration(method, path string, duration float64) { 74 | h.httpRequestDuration.WithLabelValues(method, path).Observe(duration) 75 | } 76 | 77 | // NewHttpApiMetrics returns a new instance of httpApiMetrics. 78 | func NewHttpApiMetrics() HttpApiMetrics { 79 | return &httpApiMetrics{ 80 | httpRequestsTotal: promauto.NewCounter(prometheus.CounterOpts{ 81 | Name: "http_total_requests_total", 82 | Help: "The total number of processed HTTP requests", 83 | }), 84 | http400Total: promauto.NewCounter(prometheus.CounterOpts{ 85 | Name: "http_requests_4xx_total", 86 | Help: "The total number of processed HTTP requests with status 4xx", 87 | }), 88 | http500Total: promauto.NewCounter(prometheus.CounterOpts{ 89 | Name: "http_requests_5xx_total", 90 | Help: "The total number of processed HTTP requests with status 5xx", 91 | }), 92 | httpRequest: promauto.NewCounterVec(prometheus.CounterOpts{ 93 | Name: "http_requests_total", 94 | Help: "The total number of processed http requests", 95 | }, []string{"method", "path", "status_code"}), 96 | httpRequestContentLength: promauto.NewCounterVec(prometheus.CounterOpts{ 97 | Name: "http_requests_content_length_total", 98 | Help: "The umber of bytes received in each http request", 99 | }, []string{"method", "path"}), 100 | httpRequestResponseSize: promauto.NewCounterVec(prometheus.CounterOpts{ 101 | Name: "http_requests_response_size_total", 102 | Help: "The number of bytes returned in each http request", 103 | }, []string{"method", "path"}), 104 | httpRequestDuration: promauto.NewHistogramVec(prometheus.HistogramOpts{ 105 | Name: "http_requests_request_duration", 106 | Help: "Percentiles of HTTP request latencies in seconds", 107 | Buckets: getBucketHttpMetrics(), 108 | }, []string{"method", "path"}), 109 | } 110 | } 111 | 112 | // getBucketHttpMetrics returns the buckets for the http metrics. 113 | func getBucketHttpMetrics() []float64 { 114 | return []float64{ 115 | 0.005, 116 | 0.01, 117 | 0.015, 118 | 0.02, 119 | 0.025, 120 | 0.03, 121 | 0.05, 122 | 0.07, 123 | 0.09, 124 | 0.1, 125 | 0.2, 126 | 0.3, 127 | 0.4, 128 | 0.5, 129 | 0.7, 130 | 0.9, 131 | 1, 132 | 1.5, 133 | 2, 134 | 3, 135 | 5, 136 | 7, 137 | 10, 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /pkg/metrics/mock/http_middleware.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./http_middleware.go 3 | 4 | // Package mock_metrics is a generated GoMock package. 5 | package mock_metrics 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "go.uber.org/mock/gomock" 11 | ) 12 | 13 | // MockHttpApiMetrics is a mock of HttpApiMetrics interface. 14 | type MockHttpApiMetrics struct { 15 | ctrl *gomock.Controller 16 | recorder *MockHttpApiMetricsMockRecorder 17 | } 18 | 19 | // MockHttpApiMetricsMockRecorder is the mock recorder for MockHttpApiMetrics. 20 | type MockHttpApiMetricsMockRecorder struct { 21 | mock *MockHttpApiMetrics 22 | } 23 | 24 | // NewMockHttpApiMetrics creates a new mock instance. 25 | func NewMockHttpApiMetrics(ctrl *gomock.Controller) *MockHttpApiMetrics { 26 | mock := &MockHttpApiMetrics{ctrl: ctrl} 27 | mock.recorder = &MockHttpApiMetricsMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use. 32 | func (m *MockHttpApiMetrics) EXPECT() *MockHttpApiMetricsMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Collect400TotalRequests mocks base method. 37 | func (m *MockHttpApiMetrics) Collect400TotalRequests() { 38 | m.ctrl.T.Helper() 39 | m.ctrl.Call(m, "Collect400TotalRequests") 40 | } 41 | 42 | // Collect400TotalRequests indicates an expected call of Collect400TotalRequests. 43 | func (mr *MockHttpApiMetricsMockRecorder) Collect400TotalRequests() *gomock.Call { 44 | mr.mock.ctrl.T.Helper() 45 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Collect400TotalRequests", reflect.TypeOf((*MockHttpApiMetrics)(nil).Collect400TotalRequests)) 46 | } 47 | 48 | // Collect500TotalRequests mocks base method. 49 | func (m *MockHttpApiMetrics) Collect500TotalRequests() { 50 | m.ctrl.T.Helper() 51 | m.ctrl.Call(m, "Collect500TotalRequests") 52 | } 53 | 54 | // Collect500TotalRequests indicates an expected call of Collect500TotalRequests. 55 | func (mr *MockHttpApiMetricsMockRecorder) Collect500TotalRequests() *gomock.Call { 56 | mr.mock.ctrl.T.Helper() 57 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Collect500TotalRequests", reflect.TypeOf((*MockHttpApiMetrics)(nil).Collect500TotalRequests)) 58 | } 59 | 60 | // CollectRequest mocks base method. 61 | func (m *MockHttpApiMetrics) CollectRequest(method, path string, statusCode int) { 62 | m.ctrl.T.Helper() 63 | m.ctrl.Call(m, "CollectRequest", method, path, statusCode) 64 | } 65 | 66 | // CollectRequest indicates an expected call of CollectRequest. 67 | func (mr *MockHttpApiMetricsMockRecorder) CollectRequest(method, path, statusCode interface{}) *gomock.Call { 68 | mr.mock.ctrl.T.Helper() 69 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequest", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequest), method, path, statusCode) 70 | } 71 | 72 | // CollectRequestContentLength mocks base method. 73 | func (m *MockHttpApiMetrics) CollectRequestContentLength(method, path string, contentLength float64) { 74 | m.ctrl.T.Helper() 75 | m.ctrl.Call(m, "CollectRequestContentLength", method, path, contentLength) 76 | } 77 | 78 | // CollectRequestContentLength indicates an expected call of CollectRequestContentLength. 79 | func (mr *MockHttpApiMetricsMockRecorder) CollectRequestContentLength(method, path, contentLength interface{}) *gomock.Call { 80 | mr.mock.ctrl.T.Helper() 81 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequestContentLength", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequestContentLength), method, path, contentLength) 82 | } 83 | 84 | // CollectRequestDuration mocks base method. 85 | func (m *MockHttpApiMetrics) CollectRequestDuration(method, path string, duration float64) { 86 | m.ctrl.T.Helper() 87 | m.ctrl.Call(m, "CollectRequestDuration", method, path, duration) 88 | } 89 | 90 | // CollectRequestDuration indicates an expected call of CollectRequestDuration. 91 | func (mr *MockHttpApiMetricsMockRecorder) CollectRequestDuration(method, path, duration interface{}) *gomock.Call { 92 | mr.mock.ctrl.T.Helper() 93 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequestDuration", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequestDuration), method, path, duration) 94 | } 95 | 96 | // CollectRequestResponseSize mocks base method. 97 | func (m *MockHttpApiMetrics) CollectRequestResponseSize(method, path string, contentLength float64) { 98 | m.ctrl.T.Helper() 99 | m.ctrl.Call(m, "CollectRequestResponseSize", method, path, contentLength) 100 | } 101 | 102 | // CollectRequestResponseSize indicates an expected call of CollectRequestResponseSize. 103 | func (mr *MockHttpApiMetricsMockRecorder) CollectRequestResponseSize(method, path, contentLength interface{}) *gomock.Call { 104 | mr.mock.ctrl.T.Helper() 105 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequestResponseSize", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequestResponseSize), method, path, contentLength) 106 | } 107 | 108 | // CollectTotalRequests mocks base method. 109 | func (m *MockHttpApiMetrics) CollectTotalRequests() { 110 | m.ctrl.T.Helper() 111 | m.ctrl.Call(m, "CollectTotalRequests") 112 | } 113 | 114 | // CollectTotalRequests indicates an expected call of CollectTotalRequests. 115 | func (mr *MockHttpApiMetricsMockRecorder) CollectTotalRequests() *gomock.Call { 116 | mr.mock.ctrl.T.Helper() 117 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectTotalRequests", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectTotalRequests)) 118 | } 119 | -------------------------------------------------------------------------------- /pkg/stackit/options.go: -------------------------------------------------------------------------------- 1 | package stackit 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" 9 | ) 10 | 11 | // SetConfigOptions sets the default config options for the STACKIT 12 | // client and determines which type of authorization to use, depending on the 13 | // passed bearerToken and keyPath parameters. If no baseURL or an invalid 14 | // combination of auth options is given (neither or both), the function returns 15 | // an error. 16 | func SetConfigOptions(baseURL, bearerToken, keyPath string) ([]stackitconfig.ConfigurationOption, error) { 17 | if len(baseURL) == 0 { 18 | return nil, fmt.Errorf("base-url is required") 19 | } 20 | 21 | options := []stackitconfig.ConfigurationOption{ 22 | stackitconfig.WithHTTPClient(&http.Client{ 23 | Timeout: 10 * time.Second, 24 | }), 25 | stackitconfig.WithEndpoint(baseURL), 26 | } 27 | 28 | bearerTokenSet := len(bearerToken) > 0 29 | keyPathSet := len(keyPath) > 0 30 | 31 | if (!bearerTokenSet && !keyPathSet) || (bearerTokenSet && keyPathSet) { 32 | return nil, fmt.Errorf("exactly only one of auth-token or auth-key-path is required") 33 | } 34 | 35 | if bearerTokenSet { 36 | return append(options, stackitconfig.WithToken(bearerToken)), nil 37 | } 38 | 39 | return append(options, stackitconfig.WithServiceAccountKeyPath(keyPath)), nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/stackit/options_test.go: -------------------------------------------------------------------------------- 1 | package stackit 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMissingBaseURL(t *testing.T) { 10 | t.Parallel() 11 | 12 | options, err := SetConfigOptions("", "", "") 13 | assert.ErrorContains(t, err, "base-url") 14 | assert.Nil(t, options) 15 | } 16 | 17 | func TestBothAuthOptionsMissing(t *testing.T) { 18 | t.Parallel() 19 | 20 | options, err := SetConfigOptions("https://example.com", "", "") 21 | assert.ErrorContains(t, err, "auth-token or auth-key-path") 22 | assert.Nil(t, options) 23 | } 24 | 25 | func TestBothAuthOptionsSet(t *testing.T) { 26 | t.Parallel() 27 | 28 | options, err := SetConfigOptions("https://example.com", "token", "key/path") 29 | assert.ErrorContains(t, err, "auth-token or auth-key-path") 30 | assert.Nil(t, options) 31 | } 32 | 33 | func TestBearerTokenSet(t *testing.T) { 34 | t.Parallel() 35 | 36 | options, err := SetConfigOptions("https://example.com", "token", "") 37 | assert.NoError(t, err) 38 | assert.Len(t, options, 3) 39 | } 40 | 41 | func TestKeyPathSet(t *testing.T) { 42 | t.Parallel() 43 | 44 | options, err := SetConfigOptions("https://example.com", "", "key/path") 45 | assert.NoError(t, err) 46 | assert.Len(t, options, 3) 47 | } 48 | --------------------------------------------------------------------------------