├── .dockerignore ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json └── workflows │ ├── codeql-analysis.yml │ ├── golangci-lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── .markdownlint.json ├── Dockerfile ├── LICENSE ├── Makefile ├── PROJECT ├── README.md ├── api └── v1 │ ├── account_types.go │ ├── common.go │ ├── condition_types.go │ ├── dnsrecord_types.go │ ├── doc.go │ ├── groupversion_info.go │ ├── ip_types.go │ ├── zone_types.go │ └── zz_generated.deepcopy.go ├── cmd └── main.go ├── config ├── crd │ ├── bases │ │ ├── cloudflare-operator.io_accounts.yaml │ │ ├── cloudflare-operator.io_dnsrecords.yaml │ │ ├── cloudflare-operator.io_ips.yaml │ │ └── cloudflare-operator.io_zones.yaml │ ├── kustomization.yaml │ └── kustomizeconfig.yaml ├── default │ ├── kustomization.yaml │ ├── manager_auth_proxy_patch.yaml │ └── manager_config_patch.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── manifests │ ├── grafana │ │ └── dashboards │ │ │ └── overview.json │ └── kustomization.yaml ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── rbac │ ├── account_editor_role.yaml │ ├── account_viewer_role.yaml │ ├── auth_proxy_client_clusterrole.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── dnsrecord_editor_role.yaml │ ├── dnsrecord_viewer_role.yaml │ ├── ip_editor_role.yaml │ ├── ip_viewer_role.yaml │ ├── kustomization.yaml │ ├── leader_election_role.yaml │ ├── leader_election_role_binding.yaml │ ├── role.yaml │ ├── role_binding.yaml │ ├── service_account.yaml │ ├── zone_editor_role.yaml │ └── zone_viewer_role.yaml ├── samples │ ├── cloudflareoperatorio_v1_account.yaml │ ├── cloudflareoperatorio_v1_dnsrecord.yaml │ ├── cloudflareoperatorio_v1_ip.yaml │ ├── cloudflareoperatorio_v1_zone.yaml │ ├── kustomization.yaml │ └── networking_v1_ingress.yaml └── scorecard │ ├── bases │ └── config.yaml │ ├── kustomization.yaml │ └── patches │ ├── basic.config.yaml │ └── olm.config.yaml ├── go.mod ├── go.sum ├── hack ├── api-docs │ ├── config.json │ └── template │ │ ├── members.tpl │ │ ├── pkg.tpl │ │ └── type.tpl └── boilerplate.go.txt ├── internal ├── conditions │ ├── conditions.go │ └── conditions_test.go ├── controller │ ├── account_controller.go │ ├── account_controller_test.go │ ├── dnsrecord_controller.go │ ├── dnsrecord_controller_test.go │ ├── ingress_controller.go │ ├── ingress_controller_test.go │ ├── ip_controller.go │ ├── ip_controller_test.go │ ├── suite_test.go │ ├── zone_controller.go │ └── zone_controller_test.go ├── errors │ ├── ignore.go │ └── is.go ├── metrics │ └── metrics.go └── predicates │ ├── predicates.go │ └── predicates_test.go └── test ├── e2e ├── e2e_suite_test.go └── e2e_test.go └── utils └── utils.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | 3 | - [ ] The PR has a meaningful title. It will be used to auto generate the 4 | changelog. 5 | The PR has a meaningful description that sums up the change. It will be 6 | linked in the changelog. 7 | - [ ] PR contains a single logical change (to build a better changelog). 8 | - [ ] Update the documentation. 9 | - [ ] Categorize the PR by adding one of the labels: 10 | `bug`, `enhancement`, `documentation`, `change`, `breaking`, `dependency` 11 | as they show up in the changelog. 12 | - [ ] Link this PR to related issues or PRs. 13 | 14 | 22 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:recommended"], 3 | "labels": ["dependency"], 4 | "postUpdateOptions": ["gomodTidy"], 5 | "packageRules": [ 6 | { 7 | "matchPackageNames": ["k8s.io/client-go"], 8 | "allowedVersions": "< 1.0.0" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [master] 17 | paths-ignore: 18 | - .github/** 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: [master] 22 | paths-ignore: 23 | - .github/** 24 | schedule: 25 | - cron: "18 7 * * 0" 26 | 27 | jobs: 28 | analyze: 29 | name: Analyze 30 | runs-on: ubuntu-latest 31 | permissions: 32 | actions: read 33 | contents: read 34 | security-events: write 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: ["go"] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 41 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 42 | 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | 47 | # Initializes the CodeQL tools for scanning. 48 | - name: Initialize CodeQL 49 | uses: github/codeql-action/init@v3 50 | with: 51 | languages: ${{ matrix.language }} 52 | # If you wish to specify custom queries, you can do so here or in a config file. 53 | # By default, queries listed here will override any specified in a config file. 54 | # Prefix the list here with "+" to use these queries and those in the config file. 55 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v3 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 https://git.io/JvXDl 64 | 65 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 66 | # and modify them (or add more) to build your code if your project 67 | # uses a compiled language 68 | 69 | #- run: | 70 | # make bootstrap 71 | # make release 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v3 75 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: golangci-lint 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - .github/** 9 | pull_request: 10 | paths-ignore: 11 | - .github/** 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | golangci: 18 | name: lint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Determine Go version from go.mod 23 | run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ env.GO_VERSION }} 28 | - name: golangci-lint 29 | uses: golangci/golangci-lint-action@v7 30 | with: 31 | args: --issues-exit-code=0 --timeout=3m ./... 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Determine Go version from go.mod 18 | run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ env.GO_VERSION }} 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: Cache Go modules 28 | uses: actions/cache@v4 29 | with: 30 | path: ~/go/pkg/mod 31 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 32 | restore-keys: | 33 | ${{ runner.os }}-go- 34 | - name: Login to DockerHub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKERHUB_USERNAME }} 38 | password: ${{ secrets.DOCKERHUB_TOKEN }} 39 | - name: Log in to GitHub Container registry 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Generate CRDs 46 | run: make kustomize && make crd && git reset --hard 47 | - name: Run GoReleaser 48 | uses: goreleaser/goreleaser-action@v6 49 | if: success() && startsWith(github.ref, 'refs/tags/') 50 | with: 51 | version: latest 52 | args: release 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run tests 3 | 4 | on: 5 | workflow_call: 6 | pull_request: 7 | branches: 8 | - master 9 | push: 10 | branches: 11 | - master 12 | workflow_dispatch: 13 | 14 | env: 15 | KUBERNETES_VERSION: "1.32.0" 16 | 17 | jobs: 18 | tests: 19 | name: Run Tests 20 | runs-on: ubuntu-latest 21 | 22 | permissions: 23 | contents: read 24 | 25 | steps: 26 | - name: Check out code 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | submodules: recursive 31 | 32 | - name: Set up Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version-file: "go.mod" 36 | check-latest: true 37 | cache: true 38 | 39 | - name: Set up Kind Cluster 40 | run: | 41 | make kind 42 | 43 | - name: Run Unit Tests 44 | env: 45 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 46 | CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }} 47 | run: | 48 | make test 49 | 50 | - name: Run E2E Tests 51 | env: 52 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 53 | run: | 54 | make test-e2e 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin/* 9 | Dockerfile.cross 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 | # Go workspace file 18 | go.work 19 | 20 | # Kubernetes Generated files - skip generated files, except for vendored files 21 | !vendor/**/zz_generated.* 22 | 23 | # editor and IDE paraphernalia 24 | .idea 25 | .vscode 26 | *.swp 27 | *.swo 28 | *~ 29 | 30 | dist/ 31 | crds.yaml 32 | api-ref.html 33 | .github/release-notes.md 34 | testdata/ 35 | .envrc 36 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | run: 4 | allow-parallel-runners: true 5 | linters: 6 | default: none 7 | enable: 8 | - copyloopvar 9 | - dupl 10 | - errcheck 11 | - ginkgolinter 12 | - goconst 13 | - gocyclo 14 | - govet 15 | - ineffassign 16 | - lll 17 | - misspell 18 | - nakedret 19 | - prealloc 20 | - revive 21 | - staticcheck 22 | - unconvert 23 | - unparam 24 | - unused 25 | settings: 26 | revive: 27 | rules: 28 | - name: comment-spacings 29 | exclusions: 30 | generated: lax 31 | rules: 32 | - linters: 33 | - lll 34 | path: api/* 35 | - linters: 36 | - dupl 37 | - lll 38 | path: internal/* 39 | paths: 40 | - third_party$ 41 | - builtin$ 42 | - examples$ 43 | formatters: 44 | enable: 45 | - gofmt 46 | - goimports 47 | exclusions: 48 | generated: lax 49 | paths: 50 | - third_party$ 51 | - builtin$ 52 | - examples$ 53 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | project_name: cloudflare-operator 4 | before: 5 | hooks: 6 | - go mod tidy 7 | - go generate ./... 8 | builds: 9 | - main: ./cmd/main.go 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | dockers: 18 | - image_templates: 19 | - ghcr.io/containeroo/cloudflare-operator:{{ .Tag }}-amd64 20 | - containeroo/cloudflare-operator:{{ .Tag }}-amd64 21 | use: buildx 22 | dockerfile: Dockerfile 23 | extra_files: 24 | - go.mod 25 | - go.sum 26 | - cmd/main.go 27 | - api 28 | - internal 29 | build_flag_templates: 30 | - --pull 31 | - --platform=linux/amd64 32 | - --label=org.opencontainers.image.title={{ .ProjectName }} 33 | - --label=org.opencontainers.image.description={{ .ProjectName }} 34 | - --label=org.opencontainers.image.url=https://github.com/containeroo/cloudflare-operator 35 | - --label=org.opencontainers.image.source=https://github.com/containeroo/cloudflare-operator 36 | - --label=org.opencontainers.image.version={{ .Version }} 37 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 38 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 39 | - --label=org.opencontainers.image.licenses="GNU General Public License v3.0" 40 | - image_templates: 41 | - ghcr.io/containeroo/cloudflare-operator:{{ .Tag }}-arm64 42 | - containeroo/cloudflare-operator:{{ .Tag }}-arm64 43 | use: buildx 44 | dockerfile: Dockerfile 45 | extra_files: 46 | - go.mod 47 | - go.sum 48 | - cmd/main.go 49 | - api 50 | - internal 51 | goarch: arm64 52 | build_flag_templates: 53 | - --pull 54 | - --platform=linux/arm64 55 | - --label=org.opencontainers.image.title={{ .ProjectName }} 56 | - --label=org.opencontainers.image.description={{ .ProjectName }} 57 | - --label=org.opencontainers.image.url=https://github.com/containeroo/cloudflare-operator 58 | - --label=org.opencontainers.image.source=https://github.com/containeroo/cloudflare-operator 59 | - --label=org.opencontainers.image.version={{ .Version }} 60 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 61 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 62 | - --label=org.opencontainers.image.licenses="GNU General Public License v3.0" 63 | docker_manifests: 64 | - name_template: containeroo/cloudflare-operator:{{ .Tag }} 65 | image_templates: 66 | - containeroo/cloudflare-operator:{{ .Tag }}-amd64 67 | - containeroo/cloudflare-operator:{{ .Tag }}-arm64 68 | - name_template: ghcr.io/containeroo/cloudflare-operator:{{ .Tag }} 69 | image_templates: 70 | - ghcr.io/containeroo/cloudflare-operator:{{ .Tag }}-amd64 71 | - ghcr.io/containeroo/cloudflare-operator:{{ .Tag }}-arm64 72 | - name_template: containeroo/cloudflare-operator:latest 73 | image_templates: 74 | - containeroo/cloudflare-operator:{{ .Tag }}-amd64 75 | - containeroo/cloudflare-operator:{{ .Tag }}-arm64 76 | - name_template: ghcr.io/containeroo/cloudflare-operator:latest 77 | image_templates: 78 | - ghcr.io/containeroo/cloudflare-operator:{{ .Tag }}-amd64 79 | - ghcr.io/containeroo/cloudflare-operator:{{ .Tag }}-arm64 80 | release: 81 | extra_files: 82 | - glob: ./crds.yaml 83 | prerelease: auto 84 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/main/schema/markdownlint-config-schema.json", 3 | "line-length": false 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.24 AS builder 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | WORKDIR /workspace 7 | # Copy the Go Modules manifests 8 | COPY go.mod go.mod 9 | COPY go.sum go.sum 10 | # cache deps before building and copying source so that we don't need to re-download as much 11 | # and so that source changes don't invalidate our downloaded layer 12 | RUN go mod download 13 | 14 | # Copy the go source 15 | COPY cmd/main.go cmd/main.go 16 | COPY api/ api/ 17 | COPY internal/ internal/ 18 | 19 | # Build 20 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 21 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 22 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 23 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 24 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go 25 | 26 | # Use distroless as minimal base image to package the manager binary 27 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 28 | FROM gcr.io/distroless/static:nonroot 29 | WORKDIR / 30 | COPY --from=builder /workspace/manager . 31 | USER 65532:65532 32 | 33 | ENTRYPOINT ["/manager"] 34 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | # Code generated by tool. DO NOT EDIT. 2 | # This file is used to track the info used to scaffold your project 3 | # and allow the plugins properly work. 4 | # More info: https://book.kubebuilder.io/reference/project-config.html 5 | domain: cloudflare-operator.io 6 | layout: 7 | - go.kubebuilder.io/v4 8 | plugins: 9 | manifests.sdk.operatorframework.io/v2: {} 10 | scorecard.sdk.operatorframework.io/v2: {} 11 | projectName: cloudflare-operator 12 | repo: github.com/containeroo/cloudflare-operator 13 | resources: 14 | - api: 15 | crdVersion: v1 16 | namespaced: true 17 | controller: true 18 | domain: cloudflare-operator.io 19 | kind: DNSRecord 20 | path: github.com/containeroo/cloudflare-operator/api/v1 21 | version: v1 22 | - controller: true 23 | domain: k8s.io 24 | group: networking 25 | kind: Ingress 26 | path: k8s.io/api/networking/v1 27 | version: v1 28 | - api: 29 | crdVersion: v1 30 | controller: true 31 | domain: cloudflare-operator.io 32 | kind: IP 33 | path: github.com/containeroo/cloudflare-operator/api/v1 34 | version: v1 35 | - api: 36 | crdVersion: v1 37 | controller: true 38 | domain: cloudflare-operator.io 39 | kind: Account 40 | path: github.com/containeroo/cloudflare-operator/api/v1 41 | version: v1 42 | - api: 43 | crdVersion: v1 44 | controller: true 45 | domain: cloudflare-operator.io 46 | kind: Zone 47 | path: github.com/containeroo/cloudflare-operator/api/v1 48 | version: v1 49 | version: "3" 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudflare-operator 2 | 3 | The goal of cloudflare-operator is to manage Cloudflare DNS records using Kubernetes objects. 4 | 5 | cloudflare-operator is built from the ground up to use Kubernetes' API extension system. 6 | 7 | ## Who is cloudflare-operator for? 8 | 9 | cloudflare-operator helps to: 10 | 11 | - Manage Cloudflare DNS records using Kubernetes objects 12 | - Keep Cloudflare DNS records up to date 13 | - Update your external IP address on Cloudflare DNS records 14 | 15 | ## What can I do with cloudflare-operator? 16 | 17 | cloudflare-operator is based on a set of Kubernetes API extensions ("custom resources"), which control Cloudflare DNS records. 18 | 19 | ## Where do I start? 20 | 21 | Following [this](https://containeroo.ch/docs/cloudflare-operator) guide will just take a couple of minutes to complete. After installing the cloudflare-operator helm chart and adding some annotation to your ingresses, cloudflare-operator will take care of your Cloudflare DNS records. 22 | 23 | ## More detail on what’s in cloudflare-operator 24 | 25 | Features: 26 | 27 | - Add, update and delete Cloudflare DNS records 28 | - Update Cloudflare DNS records if your external IP address changes 29 | 30 | ## Disclaimer 31 | 32 | This is not an official Cloudflare project. Use at your own risk. 33 | 34 | If you encounter any issues, please open an issue on GitHub. 35 | 36 | If everything works fine, please send your compliments to Cloudflare. 37 | 38 | If everthing does not work fine, please send your complaints to us :D 39 | 40 | Cloudflare is a registered trademark of Cloudflare, Inc. 41 | -------------------------------------------------------------------------------- /api/v1/account_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | import ( 20 | corev1 "k8s.io/api/core/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | type AccountSpecApiToken struct { 25 | // Secret containing the API token (key must be named "apiToken") 26 | SecretRef corev1.SecretReference `json:"secretRef"` 27 | } 28 | 29 | // AccountSpec defines the desired state of Account 30 | type AccountSpec struct { 31 | // Cloudflare API token 32 | ApiToken AccountSpecApiToken `json:"apiToken"` 33 | // Interval to check account status 34 | // +kubebuilder:default="5m" 35 | // +optional 36 | Interval metav1.Duration `json:"interval,omitempty"` 37 | // List of zone names that should be managed by cloudflare-operator 38 | // Deprecated and will be removed in a future release 39 | // +optional 40 | // +deprecated 41 | ManagedZones []string `json:"managedZones,omitempty"` 42 | } 43 | 44 | // AccountStatus defines the observed state of Account 45 | type AccountStatus struct { 46 | // Conditions contains the different condition statuses for the Account object. 47 | // +optional 48 | Conditions []metav1.Condition `json:"conditions"` 49 | } 50 | 51 | // +kubebuilder:object:root=true 52 | // +kubebuilder:subresource:status 53 | // +kubebuilder:resource:scope=Cluster 54 | 55 | // Account is the Schema for the accounts API 56 | // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type == "Ready")].status` 57 | type Account struct { 58 | metav1.TypeMeta `json:",inline"` 59 | metav1.ObjectMeta `json:"metadata,omitempty"` 60 | 61 | Spec AccountSpec `json:"spec,omitempty"` 62 | Status AccountStatus `json:"status,omitempty"` 63 | } 64 | 65 | // GetConditions returns the status conditions of the object. 66 | func (in *Account) GetConditions() []metav1.Condition { 67 | return in.Status.Conditions 68 | } 69 | 70 | // SetConditions sets the status conditions on the object. 71 | func (in *Account) SetConditions(conditions []metav1.Condition) { 72 | in.Status.Conditions = conditions 73 | } 74 | 75 | // +kubebuilder:object:root=true 76 | 77 | // AccountList contains a list of Account 78 | type AccountList struct { 79 | metav1.TypeMeta `json:",inline"` 80 | metav1.ListMeta `json:"metadata,omitempty"` 81 | Items []Account `json:"items"` 82 | } 83 | 84 | func init() { 85 | SchemeBuilder.Register(&Account{}, &AccountList{}) 86 | } 87 | -------------------------------------------------------------------------------- /api/v1/common.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | const CloudflareOperatorFinalizer = "cloudflare-operator.io/finalizer" 20 | -------------------------------------------------------------------------------- /api/v1/condition_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | const ( 20 | // ConditionTypeReady represents the fact that the object is ready. 21 | ConditionTypeReady string = "Ready" 22 | 23 | // ConditionReasonReady represents the fact that the object is ready. 24 | ConditionReasonReady string = "Ready" 25 | 26 | // ConditionReasonNotReady represents the fact that the object is not ready. 27 | ConditionReasonNotReady string = "NotReady" 28 | 29 | // ConditionReasonFailed represents the fact that the object has failed. 30 | ConditionReasonFailed string = "Failed" 31 | ) 32 | -------------------------------------------------------------------------------- /api/v1/dnsrecord_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | import ( 20 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | type DNSRecordSpecIPRef struct { 25 | // Name of the IP object 26 | // +optional 27 | Name string `json:"name,omitempty"` 28 | } 29 | 30 | // DNSRecordSpec defines the desired state of DNSRecord 31 | type DNSRecordSpec struct { 32 | // DNS record name (e.g. example.com) 33 | // +kubebuilder:validation:MaxLength=255 34 | Name string `json:"name"` 35 | // DNS record content (e.g. 127.0.0.1) 36 | // +optional 37 | Content string `json:"content,omitempty"` 38 | // Reference to an IP object 39 | // +optional 40 | IPRef DNSRecordSpecIPRef `json:"ipRef,omitempty"` 41 | // DNS record type 42 | // +kubebuilder:default=A 43 | // +optional 44 | Type string `json:"type,omitempty"` 45 | // Whether the record is receiving the performance and security benefits of Cloudflare 46 | // +kubebuilder:default=true 47 | // +optional 48 | Proxied *bool `json:"proxied,omitempty"` 49 | // Time to live, in seconds, of the DNS record. Must be between 60 and 86400, or 1 for "automatic" (e.g. 3600) 50 | // +kubebuilder:validation:Minimum=1 51 | // +kubebuilder:validation:Maximum=86400 52 | // +kubebuilder:default=1 53 | // +optional 54 | TTL int `json:"ttl,omitempty"` 55 | // Data holds arbitrary key-value pairs used to further configure the DNS record 56 | // +optional 57 | Data *apiextensionsv1.JSON `json:"data,omitempty"` 58 | // Required for MX, SRV and URI records; unused by other record types. Records with lower priorities are preferred. 59 | // +kubebuilder:validation:Minimum=0 60 | // +kubebuilder:validation:Maximum=65535 61 | // +optional 62 | Priority *uint16 `json:"priority,omitempty"` 63 | // Interval to check DNSRecord 64 | // +kubebuilder:default="5m" 65 | // +optional 66 | Interval metav1.Duration `json:"interval,omitempty"` 67 | } 68 | 69 | // DNSRecordStatus defines the observed state of DNSRecord 70 | type DNSRecordStatus struct { 71 | // Conditions contains the different condition statuses for the DNSRecord object. 72 | // +optional 73 | Conditions []metav1.Condition `json:"conditions"` 74 | // Cloudflare DNS record ID 75 | // +optional 76 | RecordID string `json:"recordID,omitempty"` 77 | } 78 | 79 | const ( 80 | IPRefIndexKey string = ".spec.ipRef.name" 81 | OwnerRefUIDIndexKey string = ".metadata.ownerReferences.uid" 82 | ) 83 | 84 | // +kubebuilder:object:root=true 85 | // +kubebuilder:subresource:status 86 | 87 | // DNSRecord is the Schema for the dnsrecords API 88 | // +kubebuilder:printcolumn:name="Record Name",type="string",JSONPath=".spec.name" 89 | // +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type" 90 | // +kubebuilder:printcolumn:name="Content",type="string",JSONPath=".spec.content",priority=1 91 | // +kubebuilder:printcolumn:name="Proxied",type="boolean",JSONPath=".spec.proxied",priority=1 92 | // +kubebuilder:printcolumn:name="TTL",type="integer",JSONPath=".spec.ttl",priority=1 93 | // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type == "Ready")].status` 94 | type DNSRecord struct { 95 | metav1.TypeMeta `json:",inline"` 96 | metav1.ObjectMeta `json:"metadata,omitempty"` 97 | 98 | Spec DNSRecordSpec `json:"spec,omitempty"` 99 | Status DNSRecordStatus `json:"status,omitempty"` 100 | } 101 | 102 | // GetConditions returns the status conditions of the object. 103 | func (in *DNSRecord) GetConditions() []metav1.Condition { 104 | return in.Status.Conditions 105 | } 106 | 107 | // SetConditions sets the status conditions on the object. 108 | func (in *DNSRecord) SetConditions(conditions []metav1.Condition) { 109 | in.Status.Conditions = conditions 110 | } 111 | 112 | // +kubebuilder:object:root=true 113 | 114 | // DNSRecordList contains a list of DNSRecord 115 | type DNSRecordList struct { 116 | metav1.TypeMeta `json:",inline"` 117 | metav1.ListMeta `json:"metadata,omitempty"` 118 | Items []DNSRecord `json:"items"` 119 | } 120 | 121 | func init() { 122 | SchemeBuilder.Register(&DNSRecord{}, &DNSRecordList{}) 123 | } 124 | -------------------------------------------------------------------------------- /api/v1/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package v1 contains API Schema definitions for the source v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=cloudflare-operator.io 20 | package v1 21 | -------------------------------------------------------------------------------- /api/v1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package v1 contains API Schema definitions for the cloudflare-operator.io v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=cloudflare-operator.io 20 | package v1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects 29 | GroupVersion = schema.GroupVersion{Group: "cloudflare-operator.io", Version: "v1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /api/v1/ip_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | import ( 20 | corev1 "k8s.io/api/core/v1" 21 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | ) 24 | 25 | type IPSpecIPSources struct { 26 | // URL of the IP source (e.g. https://checkip.amazonaws.com) 27 | URL string `json:"url,omitempty"` 28 | // RequestBody to be sent to the URL 29 | // +optional 30 | RequestBody string `json:"requestBody,omitempty"` 31 | // RequestHeaders to be sent to the URL 32 | // +optional 33 | RequestHeaders *apiextensionsv1.JSON `json:"requestHeaders,omitempty"` 34 | // RequestHeadersSecretRef is a secret reference to the headers to be sent to the URL (e.g. for authentication) 35 | // where the key is the header name and the value is the header value 36 | // +optional 37 | RequestHeadersSecretRef corev1.SecretReference `json:"requestHeadersSecretRef,omitempty"` 38 | // RequestMethod defines the HTTP method to be used 39 | // +kubebuilder:validation:Enum=GET;POST;PUT;DELETE 40 | // +optional 41 | RequestMethod string `json:"requestMethod,omitempty"` 42 | // ResponseJQFilter applies a JQ filter to the response to extract the IP 43 | // +optional 44 | ResponseJQFilter string `json:"responseJQFilter,omitempty"` 45 | // PostProcessingRegex defines the regular expression to be used to extract the IP from the response or a JQ filter result 46 | // +optional 47 | PostProcessingRegex string `json:"postProcessingRegex,omitempty"` 48 | // InsecureSkipVerify defines whether to skip TLS certificate verification 49 | // +optional 50 | InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` 51 | } 52 | 53 | // IPSpec defines the desired state of IP 54 | type IPSpec struct { 55 | // IP address (omit if type is dynamic) 56 | // +optional 57 | Address string `json:"address,omitempty"` 58 | // IP address type (static or dynamic) 59 | // +kubebuilder:validation:Enum=static;dynamic 60 | // +kubebuilder:default=static 61 | // +optional 62 | Type string `json:"type,omitempty"` 63 | // Interval at which a dynamic IP should be checked 64 | // +optional 65 | Interval *metav1.Duration `json:"interval,omitempty"` 66 | // IPSources can be configured to get an IP from an external source (e.g. an API or public IP echo service) 67 | // +optional 68 | IPSources []IPSpecIPSources `json:"ipSources,omitempty"` 69 | } 70 | 71 | // IPStatus defines the observed state of IP 72 | type IPStatus struct { 73 | // Conditions contains the different condition statuses for the IP object. 74 | // +optional 75 | Conditions []metav1.Condition `json:"conditions"` 76 | } 77 | 78 | // +kubebuilder:object:root=true 79 | // +kubebuilder:subresource:status 80 | // +kubebuilder:resource:scope=Cluster 81 | 82 | // IP is the Schema for the ips API 83 | // +kubebuilder:printcolumn:name="Address",type="string",JSONPath=".spec.address" 84 | // +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type" 85 | // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type == "Ready")].status` 86 | type IP struct { 87 | metav1.TypeMeta `json:",inline"` 88 | metav1.ObjectMeta `json:"metadata,omitempty"` 89 | 90 | Spec IPSpec `json:"spec,omitempty"` 91 | Status IPStatus `json:"status,omitempty"` 92 | } 93 | 94 | // GetConditions returns the status conditions of the object. 95 | func (in *IP) GetConditions() []metav1.Condition { 96 | return in.Status.Conditions 97 | } 98 | 99 | // SetConditions sets the status conditions on the object. 100 | func (in *IP) SetConditions(conditions []metav1.Condition) { 101 | in.Status.Conditions = conditions 102 | } 103 | 104 | // +kubebuilder:object:root=true 105 | 106 | // IPList contains a list of IP 107 | type IPList struct { 108 | metav1.TypeMeta `json:",inline"` 109 | metav1.ListMeta `json:"metadata,omitempty"` 110 | Items []IP `json:"items"` 111 | } 112 | 113 | func init() { 114 | SchemeBuilder.Register(&IP{}, &IPList{}) 115 | } 116 | -------------------------------------------------------------------------------- /api/v1/zone_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | ) 22 | 23 | // ZoneSpec defines the desired state of Zone 24 | type ZoneSpec struct { 25 | // Name of the zone 26 | Name string `json:"name"` 27 | // Prune determines whether DNS records in the zone that are not managed by cloudflare-operator should be automatically removed 28 | // +kubebuilder:default=false 29 | // +optional 30 | Prune bool `json:"prune"` 31 | // Interval to check zone status 32 | // +kubebuilder:default="5m" 33 | // +optional 34 | Interval metav1.Duration `json:"interval,omitempty"` 35 | } 36 | 37 | // ZoneStatus defines the observed state of Zone 38 | type ZoneStatus struct { 39 | // ID of the zone 40 | // +optional 41 | ID string `json:"id,omitempty"` 42 | // Conditions contains the different condition statuses for the Zone object. 43 | // +optional 44 | Conditions []metav1.Condition `json:"conditions"` 45 | } 46 | 47 | const ( 48 | ZoneNameIndexKey string = ".spec.name" 49 | ) 50 | 51 | // +kubebuilder:object:root=true 52 | // +kubebuilder:subresource:status 53 | // +kubebuilder:resource:scope=Cluster 54 | 55 | // Zone is the Schema for the zones API 56 | // +kubebuilder:printcolumn:name="Zone Name",type="string",JSONPath=".spec.name" 57 | // +kubebuilder:printcolumn:name="ID",type="string",JSONPath=".status.id" 58 | // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type == "Ready")].status` 59 | type Zone struct { 60 | metav1.TypeMeta `json:",inline"` 61 | metav1.ObjectMeta `json:"metadata,omitempty"` 62 | 63 | Spec ZoneSpec `json:"spec,omitempty"` 64 | Status ZoneStatus `json:"status,omitempty"` 65 | } 66 | 67 | // GetConditions returns the status conditions of the object. 68 | func (in *Zone) GetConditions() []metav1.Condition { 69 | return in.Status.Conditions 70 | } 71 | 72 | // SetConditions sets the status conditions on the object. 73 | func (in *Zone) SetConditions(conditions []metav1.Condition) { 74 | in.Status.Conditions = conditions 75 | } 76 | 77 | // +kubebuilder:object:root=true 78 | 79 | // ZoneList contains a list of Zone 80 | type ZoneList struct { 81 | metav1.TypeMeta `json:",inline"` 82 | metav1.ListMeta `json:"metadata,omitempty"` 83 | Items []Zone `json:"items"` 84 | } 85 | 86 | func init() { 87 | SchemeBuilder.Register(&Zone{}, &ZoneList{}) 88 | } 89 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "crypto/tls" 21 | "flag" 22 | "os" 23 | "time" 24 | 25 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 26 | // to ensure that exec-entrypoint and run can make use of them. 27 | _ "k8s.io/client-go/plugin/pkg/client/auth" 28 | 29 | "k8s.io/apimachinery/pkg/runtime" 30 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 31 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/healthz" 34 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 35 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 36 | "sigs.k8s.io/controller-runtime/pkg/webhook" 37 | 38 | "github.com/cloudflare/cloudflare-go" 39 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 40 | "github.com/containeroo/cloudflare-operator/internal/controller" 41 | // +kubebuilder:scaffold:imports 42 | ) 43 | 44 | var ( 45 | scheme = runtime.NewScheme() 46 | setupLog = ctrl.Log.WithName("setup") 47 | ) 48 | 49 | const version = "v1.5.1" 50 | 51 | func init() { 52 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 53 | 54 | utilruntime.Must(cloudflareoperatoriov1.AddToScheme(scheme)) 55 | // +kubebuilder:scaffold:scheme 56 | } 57 | 58 | func main() { 59 | var ( 60 | metricsAddr string 61 | enableLeaderElection bool 62 | probeAddr string 63 | secureMetrics bool 64 | enableHTTP2 bool 65 | retryInterval time.Duration 66 | ipReconcilerHTTPClientTimeout time.Duration 67 | defaultReconcileInterval time.Duration 68 | cloudflareAPI cloudflare.API 69 | ctx = ctrl.SetupSignalHandler() 70 | ) 71 | flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") 72 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 73 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 74 | "Enable leader election for controller manager. "+ 75 | "Enabling this will ensure there is only one active controller manager.") 76 | flag.BoolVar(&secureMetrics, "metrics-secure", false, 77 | "If set the metrics endpoint is served securely") 78 | flag.BoolVar(&enableHTTP2, "enable-http2", false, 79 | "If set, HTTP/2 will be enabled for the metrics and webhook servers") 80 | flag.DurationVar(&ipReconcilerHTTPClientTimeout, "ip-reconciler-http-client-timeout", 10*time.Second, 81 | "The HTTP client timeout for the IP reconciler") 82 | flag.DurationVar(&retryInterval, "retry-interval", 10*time.Second, "The interval at which to retry failed operations") 83 | flag.DurationVar(&defaultReconcileInterval, "default-reconcile-interval", 5*time.Minute, 84 | "The default interval at which to reconcile resources") 85 | opts := zap.Options{ 86 | Development: true, 87 | } 88 | opts.BindFlags(flag.CommandLine) 89 | flag.Parse() 90 | 91 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 92 | 93 | // if the enable-http2 flag is false (the default), http/2 should be disabled 94 | // due to its vulnerabilities. More specifically, disabling http/2 will 95 | // prevent from being vulnerable to the HTTP/2 Stream Cancelation and 96 | // Rapid Reset CVEs. For more information see: 97 | // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 98 | // - https://github.com/advisories/GHSA-4374-p667-p6c8 99 | disableHTTP2 := func(c *tls.Config) { 100 | setupLog.Info("disabling http/2") 101 | c.NextProtos = []string{"http/1.1"} 102 | } 103 | 104 | tlsOpts := []func(*tls.Config){} 105 | if !enableHTTP2 { 106 | tlsOpts = append(tlsOpts, disableHTTP2) 107 | } 108 | 109 | webhookServer := webhook.NewServer(webhook.Options{ 110 | TLSOpts: tlsOpts, 111 | }) 112 | 113 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 114 | Scheme: scheme, 115 | Metrics: metricsserver.Options{ 116 | BindAddress: metricsAddr, 117 | SecureServing: secureMetrics, 118 | TLSOpts: tlsOpts, 119 | }, 120 | WebhookServer: webhookServer, 121 | HealthProbeBindAddress: probeAddr, 122 | LeaderElection: enableLeaderElection, 123 | LeaderElectionID: "447b530a.cloudflare-operator.io", 124 | LeaderElectionReleaseOnCancel: true, 125 | }) 126 | if err != nil { 127 | setupLog.Error(err, "unable to start manager") 128 | os.Exit(1) 129 | } 130 | 131 | if err = (&controller.AccountReconciler{ 132 | Client: mgr.GetClient(), 133 | Scheme: mgr.GetScheme(), 134 | CloudflareAPI: &cloudflareAPI, 135 | RetryInterval: retryInterval, 136 | }).SetupWithManager(mgr); err != nil { 137 | setupLog.Error(err, "unable to create controller", "controller", "Account") 138 | os.Exit(1) 139 | } 140 | if err = (&controller.ZoneReconciler{ 141 | Client: mgr.GetClient(), 142 | Scheme: mgr.GetScheme(), 143 | CloudflareAPI: &cloudflareAPI, 144 | RetryInterval: retryInterval, 145 | }).SetupWithManager(ctx, mgr); err != nil { 146 | setupLog.Error(err, "unable to create controller", "controller", "Zone") 147 | os.Exit(1) 148 | } 149 | if err = (&controller.IPReconciler{ 150 | Client: mgr.GetClient(), 151 | Scheme: mgr.GetScheme(), 152 | HTTPClientTimeout: ipReconcilerHTTPClientTimeout, 153 | RetryInterval: retryInterval, 154 | DefaultReconcileInterval: defaultReconcileInterval, 155 | }).SetupWithManager(mgr); err != nil { 156 | setupLog.Error(err, "unable to create controller", "controller", "IP") 157 | os.Exit(1) 158 | } 159 | if err = (&controller.IngressReconciler{ 160 | Client: mgr.GetClient(), 161 | Scheme: mgr.GetScheme(), 162 | RetryInterval: retryInterval, 163 | DefaultReconcileInterval: defaultReconcileInterval, 164 | }).SetupWithManager(mgr); err != nil { 165 | setupLog.Error(err, "unable to create controller", "controller", "Ingress") 166 | os.Exit(1) 167 | } 168 | if err = (&controller.DNSRecordReconciler{ 169 | Client: mgr.GetClient(), 170 | Scheme: mgr.GetScheme(), 171 | CloudflareAPI: &cloudflareAPI, 172 | RetryInterval: retryInterval, 173 | }).SetupWithManager(ctx, mgr); err != nil { 174 | setupLog.Error(err, "unable to create controller", "controller", "DNSRecord") 175 | os.Exit(1) 176 | } 177 | // +kubebuilder:scaffold:builder 178 | 179 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 180 | setupLog.Error(err, "unable to set up health check") 181 | os.Exit(1) 182 | } 183 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 184 | setupLog.Error(err, "unable to set up ready check") 185 | os.Exit(1) 186 | } 187 | 188 | setupLog.Info("starting cloudflare-operator " + version) 189 | if err := mgr.Start(ctx); err != nil { 190 | setupLog.Error(err, "problem running manager") 191 | os.Exit(1) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /config/crd/bases/cloudflare-operator.io_accounts.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.2 7 | name: accounts.cloudflare-operator.io 8 | spec: 9 | group: cloudflare-operator.io 10 | names: 11 | kind: Account 12 | listKind: AccountList 13 | plural: accounts 14 | singular: account 15 | scope: Cluster 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .status.conditions[?(@.type == "Ready")].status 19 | name: Ready 20 | type: string 21 | name: v1 22 | schema: 23 | openAPIV3Schema: 24 | description: Account is the Schema for the accounts API 25 | properties: 26 | apiVersion: 27 | description: |- 28 | APIVersion defines the versioned schema of this representation of an object. 29 | Servers should convert recognized schemas to the latest internal value, and 30 | may reject unrecognized values. 31 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 32 | type: string 33 | kind: 34 | description: |- 35 | Kind is a string value representing the REST resource this object represents. 36 | Servers may infer this from the endpoint the client submits requests to. 37 | Cannot be updated. 38 | In CamelCase. 39 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 40 | type: string 41 | metadata: 42 | type: object 43 | spec: 44 | description: AccountSpec defines the desired state of Account 45 | properties: 46 | apiToken: 47 | description: Cloudflare API token 48 | properties: 49 | secretRef: 50 | description: Secret containing the API token (key must be named 51 | "apiToken") 52 | properties: 53 | name: 54 | description: name is unique within a namespace to reference 55 | a secret resource. 56 | type: string 57 | namespace: 58 | description: namespace defines the space within which the 59 | secret name must be unique. 60 | type: string 61 | type: object 62 | x-kubernetes-map-type: atomic 63 | required: 64 | - secretRef 65 | type: object 66 | interval: 67 | default: 5m 68 | description: Interval to check account status 69 | type: string 70 | managedZones: 71 | description: |- 72 | List of zone names that should be managed by cloudflare-operator 73 | Deprecated and will be removed in a future release 74 | items: 75 | type: string 76 | type: array 77 | required: 78 | - apiToken 79 | type: object 80 | status: 81 | description: AccountStatus defines the observed state of Account 82 | properties: 83 | conditions: 84 | description: Conditions contains the different condition statuses 85 | for the Account object. 86 | items: 87 | description: Condition contains details for one aspect of the current 88 | state of this API Resource. 89 | properties: 90 | lastTransitionTime: 91 | description: |- 92 | lastTransitionTime is the last time the condition transitioned from one status to another. 93 | This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. 94 | format: date-time 95 | type: string 96 | message: 97 | description: |- 98 | message is a human readable message indicating details about the transition. 99 | This may be an empty string. 100 | maxLength: 32768 101 | type: string 102 | observedGeneration: 103 | description: |- 104 | observedGeneration represents the .metadata.generation that the condition was set based upon. 105 | For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date 106 | with respect to the current state of the instance. 107 | format: int64 108 | minimum: 0 109 | type: integer 110 | reason: 111 | description: |- 112 | reason contains a programmatic identifier indicating the reason for the condition's last transition. 113 | Producers of specific condition types may define expected values and meanings for this field, 114 | and whether the values are considered a guaranteed API. 115 | The value should be a CamelCase string. 116 | This field may not be empty. 117 | maxLength: 1024 118 | minLength: 1 119 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 120 | type: string 121 | status: 122 | description: status of the condition, one of True, False, Unknown. 123 | enum: 124 | - "True" 125 | - "False" 126 | - Unknown 127 | type: string 128 | type: 129 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 130 | maxLength: 316 131 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 132 | type: string 133 | required: 134 | - lastTransitionTime 135 | - message 136 | - reason 137 | - status 138 | - type 139 | type: object 140 | type: array 141 | type: object 142 | type: object 143 | served: true 144 | storage: true 145 | subresources: 146 | status: {} 147 | -------------------------------------------------------------------------------- /config/crd/bases/cloudflare-operator.io_dnsrecords.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.2 7 | name: dnsrecords.cloudflare-operator.io 8 | spec: 9 | group: cloudflare-operator.io 10 | names: 11 | kind: DNSRecord 12 | listKind: DNSRecordList 13 | plural: dnsrecords 14 | singular: dnsrecord 15 | scope: Namespaced 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .spec.name 19 | name: Record Name 20 | type: string 21 | - jsonPath: .spec.type 22 | name: Type 23 | type: string 24 | - jsonPath: .spec.content 25 | name: Content 26 | priority: 1 27 | type: string 28 | - jsonPath: .spec.proxied 29 | name: Proxied 30 | priority: 1 31 | type: boolean 32 | - jsonPath: .spec.ttl 33 | name: TTL 34 | priority: 1 35 | type: integer 36 | - jsonPath: .status.conditions[?(@.type == "Ready")].status 37 | name: Ready 38 | type: string 39 | name: v1 40 | schema: 41 | openAPIV3Schema: 42 | description: DNSRecord is the Schema for the dnsrecords API 43 | properties: 44 | apiVersion: 45 | description: |- 46 | APIVersion defines the versioned schema of this representation of an object. 47 | Servers should convert recognized schemas to the latest internal value, and 48 | may reject unrecognized values. 49 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 50 | type: string 51 | kind: 52 | description: |- 53 | Kind is a string value representing the REST resource this object represents. 54 | Servers may infer this from the endpoint the client submits requests to. 55 | Cannot be updated. 56 | In CamelCase. 57 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 58 | type: string 59 | metadata: 60 | type: object 61 | spec: 62 | description: DNSRecordSpec defines the desired state of DNSRecord 63 | properties: 64 | content: 65 | description: DNS record content (e.g. 127.0.0.1) 66 | type: string 67 | data: 68 | description: Data holds arbitrary key-value pairs used to further 69 | configure the DNS record 70 | x-kubernetes-preserve-unknown-fields: true 71 | interval: 72 | default: 5m 73 | description: Interval to check DNSRecord 74 | type: string 75 | ipRef: 76 | description: Reference to an IP object 77 | properties: 78 | name: 79 | description: Name of the IP object 80 | type: string 81 | type: object 82 | name: 83 | description: DNS record name (e.g. example.com) 84 | maxLength: 255 85 | type: string 86 | priority: 87 | description: Required for MX, SRV and URI records; unused by other 88 | record types. Records with lower priorities are preferred. 89 | maximum: 65535 90 | minimum: 0 91 | type: integer 92 | proxied: 93 | default: true 94 | description: Whether the record is receiving the performance and security 95 | benefits of Cloudflare 96 | type: boolean 97 | ttl: 98 | default: 1 99 | description: Time to live, in seconds, of the DNS record. Must be 100 | between 60 and 86400, or 1 for "automatic" (e.g. 3600) 101 | maximum: 86400 102 | minimum: 1 103 | type: integer 104 | type: 105 | default: A 106 | description: DNS record type 107 | type: string 108 | required: 109 | - name 110 | type: object 111 | status: 112 | description: DNSRecordStatus defines the observed state of DNSRecord 113 | properties: 114 | conditions: 115 | description: Conditions contains the different condition statuses 116 | for the DNSRecord object. 117 | items: 118 | description: Condition contains details for one aspect of the current 119 | state of this API Resource. 120 | properties: 121 | lastTransitionTime: 122 | description: |- 123 | lastTransitionTime is the last time the condition transitioned from one status to another. 124 | This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. 125 | format: date-time 126 | type: string 127 | message: 128 | description: |- 129 | message is a human readable message indicating details about the transition. 130 | This may be an empty string. 131 | maxLength: 32768 132 | type: string 133 | observedGeneration: 134 | description: |- 135 | observedGeneration represents the .metadata.generation that the condition was set based upon. 136 | For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date 137 | with respect to the current state of the instance. 138 | format: int64 139 | minimum: 0 140 | type: integer 141 | reason: 142 | description: |- 143 | reason contains a programmatic identifier indicating the reason for the condition's last transition. 144 | Producers of specific condition types may define expected values and meanings for this field, 145 | and whether the values are considered a guaranteed API. 146 | The value should be a CamelCase string. 147 | This field may not be empty. 148 | maxLength: 1024 149 | minLength: 1 150 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 151 | type: string 152 | status: 153 | description: status of the condition, one of True, False, Unknown. 154 | enum: 155 | - "True" 156 | - "False" 157 | - Unknown 158 | type: string 159 | type: 160 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 161 | maxLength: 316 162 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 163 | type: string 164 | required: 165 | - lastTransitionTime 166 | - message 167 | - reason 168 | - status 169 | - type 170 | type: object 171 | type: array 172 | recordID: 173 | description: Cloudflare DNS record ID 174 | type: string 175 | type: object 176 | type: object 177 | served: true 178 | storage: true 179 | subresources: 180 | status: {} 181 | -------------------------------------------------------------------------------- /config/crd/bases/cloudflare-operator.io_ips.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.2 7 | name: ips.cloudflare-operator.io 8 | spec: 9 | group: cloudflare-operator.io 10 | names: 11 | kind: IP 12 | listKind: IPList 13 | plural: ips 14 | singular: ip 15 | scope: Cluster 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .spec.address 19 | name: Address 20 | type: string 21 | - jsonPath: .spec.type 22 | name: Type 23 | type: string 24 | - jsonPath: .status.conditions[?(@.type == "Ready")].status 25 | name: Ready 26 | type: string 27 | name: v1 28 | schema: 29 | openAPIV3Schema: 30 | description: IP is the Schema for the ips API 31 | properties: 32 | apiVersion: 33 | description: |- 34 | APIVersion defines the versioned schema of this representation of an object. 35 | Servers should convert recognized schemas to the latest internal value, and 36 | may reject unrecognized values. 37 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 38 | type: string 39 | kind: 40 | description: |- 41 | Kind is a string value representing the REST resource this object represents. 42 | Servers may infer this from the endpoint the client submits requests to. 43 | Cannot be updated. 44 | In CamelCase. 45 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 46 | type: string 47 | metadata: 48 | type: object 49 | spec: 50 | description: IPSpec defines the desired state of IP 51 | properties: 52 | address: 53 | description: IP address (omit if type is dynamic) 54 | type: string 55 | interval: 56 | description: Interval at which a dynamic IP should be checked 57 | type: string 58 | ipSources: 59 | description: IPSources can be configured to get an IP from an external 60 | source (e.g. an API or public IP echo service) 61 | items: 62 | properties: 63 | insecureSkipVerify: 64 | description: InsecureSkipVerify defines whether to skip TLS 65 | certificate verification 66 | type: boolean 67 | postProcessingRegex: 68 | description: PostProcessingRegex defines the regular expression 69 | to be used to extract the IP from the response or a JQ filter 70 | result 71 | type: string 72 | requestBody: 73 | description: RequestBody to be sent to the URL 74 | type: string 75 | requestHeaders: 76 | description: RequestHeaders to be sent to the URL 77 | x-kubernetes-preserve-unknown-fields: true 78 | requestHeadersSecretRef: 79 | description: |- 80 | RequestHeadersSecretRef is a secret reference to the headers to be sent to the URL (e.g. for authentication) 81 | where the key is the header name and the value is the header value 82 | properties: 83 | name: 84 | description: name is unique within a namespace to reference 85 | a secret resource. 86 | type: string 87 | namespace: 88 | description: namespace defines the space within which the 89 | secret name must be unique. 90 | type: string 91 | type: object 92 | x-kubernetes-map-type: atomic 93 | requestMethod: 94 | description: RequestMethod defines the HTTP method to be used 95 | enum: 96 | - GET 97 | - POST 98 | - PUT 99 | - DELETE 100 | type: string 101 | responseJQFilter: 102 | description: ResponseJQFilter applies a JQ filter to the response 103 | to extract the IP 104 | type: string 105 | url: 106 | description: URL of the IP source (e.g. https://checkip.amazonaws.com) 107 | type: string 108 | type: object 109 | type: array 110 | type: 111 | default: static 112 | description: IP address type (static or dynamic) 113 | enum: 114 | - static 115 | - dynamic 116 | type: string 117 | type: object 118 | status: 119 | description: IPStatus defines the observed state of IP 120 | properties: 121 | conditions: 122 | description: Conditions contains the different condition statuses 123 | for the IP object. 124 | items: 125 | description: Condition contains details for one aspect of the current 126 | state of this API Resource. 127 | properties: 128 | lastTransitionTime: 129 | description: |- 130 | lastTransitionTime is the last time the condition transitioned from one status to another. 131 | This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. 132 | format: date-time 133 | type: string 134 | message: 135 | description: |- 136 | message is a human readable message indicating details about the transition. 137 | This may be an empty string. 138 | maxLength: 32768 139 | type: string 140 | observedGeneration: 141 | description: |- 142 | observedGeneration represents the .metadata.generation that the condition was set based upon. 143 | For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date 144 | with respect to the current state of the instance. 145 | format: int64 146 | minimum: 0 147 | type: integer 148 | reason: 149 | description: |- 150 | reason contains a programmatic identifier indicating the reason for the condition's last transition. 151 | Producers of specific condition types may define expected values and meanings for this field, 152 | and whether the values are considered a guaranteed API. 153 | The value should be a CamelCase string. 154 | This field may not be empty. 155 | maxLength: 1024 156 | minLength: 1 157 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 158 | type: string 159 | status: 160 | description: status of the condition, one of True, False, Unknown. 161 | enum: 162 | - "True" 163 | - "False" 164 | - Unknown 165 | type: string 166 | type: 167 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 168 | maxLength: 316 169 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 170 | type: string 171 | required: 172 | - lastTransitionTime 173 | - message 174 | - reason 175 | - status 176 | - type 177 | type: object 178 | type: array 179 | type: object 180 | type: object 181 | served: true 182 | storage: true 183 | subresources: 184 | status: {} 185 | -------------------------------------------------------------------------------- /config/crd/bases/cloudflare-operator.io_zones.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.2 7 | name: zones.cloudflare-operator.io 8 | spec: 9 | group: cloudflare-operator.io 10 | names: 11 | kind: Zone 12 | listKind: ZoneList 13 | plural: zones 14 | singular: zone 15 | scope: Cluster 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .spec.name 19 | name: Zone Name 20 | type: string 21 | - jsonPath: .status.id 22 | name: ID 23 | type: string 24 | - jsonPath: .status.conditions[?(@.type == "Ready")].status 25 | name: Ready 26 | type: string 27 | name: v1 28 | schema: 29 | openAPIV3Schema: 30 | description: Zone is the Schema for the zones API 31 | properties: 32 | apiVersion: 33 | description: |- 34 | APIVersion defines the versioned schema of this representation of an object. 35 | Servers should convert recognized schemas to the latest internal value, and 36 | may reject unrecognized values. 37 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 38 | type: string 39 | kind: 40 | description: |- 41 | Kind is a string value representing the REST resource this object represents. 42 | Servers may infer this from the endpoint the client submits requests to. 43 | Cannot be updated. 44 | In CamelCase. 45 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 46 | type: string 47 | metadata: 48 | type: object 49 | spec: 50 | description: ZoneSpec defines the desired state of Zone 51 | properties: 52 | interval: 53 | default: 5m 54 | description: Interval to check zone status 55 | type: string 56 | name: 57 | description: Name of the zone 58 | type: string 59 | prune: 60 | default: false 61 | description: Prune determines whether DNS records in the zone that 62 | are not managed by cloudflare-operator should be automatically removed 63 | type: boolean 64 | required: 65 | - name 66 | type: object 67 | status: 68 | description: ZoneStatus defines the observed state of Zone 69 | properties: 70 | conditions: 71 | description: Conditions contains the different condition statuses 72 | for the Zone object. 73 | items: 74 | description: Condition contains details for one aspect of the current 75 | state of this API Resource. 76 | properties: 77 | lastTransitionTime: 78 | description: |- 79 | lastTransitionTime is the last time the condition transitioned from one status to another. 80 | This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. 81 | format: date-time 82 | type: string 83 | message: 84 | description: |- 85 | message is a human readable message indicating details about the transition. 86 | This may be an empty string. 87 | maxLength: 32768 88 | type: string 89 | observedGeneration: 90 | description: |- 91 | observedGeneration represents the .metadata.generation that the condition was set based upon. 92 | For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date 93 | with respect to the current state of the instance. 94 | format: int64 95 | minimum: 0 96 | type: integer 97 | reason: 98 | description: |- 99 | reason contains a programmatic identifier indicating the reason for the condition's last transition. 100 | Producers of specific condition types may define expected values and meanings for this field, 101 | and whether the values are considered a guaranteed API. 102 | The value should be a CamelCase string. 103 | This field may not be empty. 104 | maxLength: 1024 105 | minLength: 1 106 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 107 | type: string 108 | status: 109 | description: status of the condition, one of True, False, Unknown. 110 | enum: 111 | - "True" 112 | - "False" 113 | - Unknown 114 | type: string 115 | type: 116 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 117 | maxLength: 316 118 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 119 | type: string 120 | required: 121 | - lastTransitionTime 122 | - message 123 | - reason 124 | - status 125 | - type 126 | type: object 127 | type: array 128 | id: 129 | description: ID of the zone 130 | type: string 131 | type: object 132 | type: object 133 | served: true 134 | storage: true 135 | subresources: 136 | status: {} 137 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/cloudflare-operator.io_dnsrecords.yaml 6 | - bases/cloudflare-operator.io_ips.yaml 7 | - bases/cloudflare-operator.io_accounts.yaml 8 | - bases/cloudflare-operator.io_zones.yaml 9 | #+kubebuilder:scaffold:crdkustomizeresource 10 | 11 | patches: 12 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 13 | # patches here are for enabling the conversion webhook for each CRD 14 | #- path: patches/webhook_in_dnsrecords.yaml 15 | #- path: patches/webhook_in_ips.yaml 16 | #- path: patches/webhook_in_accounts.yaml 17 | #- path: patches/webhook_in_zones.yaml 18 | #+kubebuilder:scaffold:crdkustomizewebhookpatch 19 | 20 | # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. 21 | # patches here are for enabling the CA injection for each CRD 22 | #- path: patches/cainjection_in_dnsrecords.yaml 23 | #- path: patches/cainjection_in_ips.yaml 24 | #- path: patches/cainjection_in_accounts.yaml 25 | #- path: patches/cainjection_in_zones.yaml 26 | #+kubebuilder:scaffold:crdkustomizecainjectionpatch 27 | 28 | # [WEBHOOK] To enable webhook, uncomment the following section 29 | # the following config is for teaching kustomize how to do kustomization for CRDs. 30 | 31 | #configurations: 32 | #- kustomizeconfig.yaml 33 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: cloudflare-operator-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: cloudflare-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #labels: 13 | #- includeSelectors: true 14 | # pairs: 15 | # someName: someValue 16 | 17 | resources: 18 | - ../crd 19 | - ../rbac 20 | - ../manager 21 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 22 | # crd/kustomization.yaml 23 | #- ../webhook 24 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 25 | #- ../certmanager 26 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 27 | - ../prometheus 28 | 29 | patches: 30 | # Protect the /metrics endpoint by putting it behind auth. 31 | # If you want your controller-manager to expose the /metrics 32 | # endpoint w/o any authn/z, please comment the following line. 33 | - path: manager_auth_proxy_patch.yaml 34 | 35 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 36 | # crd/kustomization.yaml 37 | #- path: manager_webhook_patch.yaml 38 | 39 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 40 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 41 | # 'CERTMANAGER' needs to be enabled to use ca injection 42 | #- path: webhookcainjection_patch.yaml 43 | 44 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 45 | # Uncomment the following replacements to add the cert-manager CA injection annotations 46 | #replacements: 47 | # - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs 48 | # kind: Certificate 49 | # group: cert-manager.io 50 | # version: v1 51 | # name: serving-cert # this name should match the one in certificate.yaml 52 | # fieldPath: .metadata.namespace # namespace of the certificate CR 53 | # targets: 54 | # - select: 55 | # kind: ValidatingWebhookConfiguration 56 | # fieldPaths: 57 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 58 | # options: 59 | # delimiter: '/' 60 | # index: 0 61 | # create: true 62 | # - select: 63 | # kind: MutatingWebhookConfiguration 64 | # fieldPaths: 65 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 66 | # options: 67 | # delimiter: '/' 68 | # index: 0 69 | # create: true 70 | # - select: 71 | # kind: CustomResourceDefinition 72 | # fieldPaths: 73 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 74 | # options: 75 | # delimiter: '/' 76 | # index: 0 77 | # create: true 78 | # - source: 79 | # kind: Certificate 80 | # group: cert-manager.io 81 | # version: v1 82 | # name: serving-cert # this name should match the one in certificate.yaml 83 | # fieldPath: .metadata.name 84 | # targets: 85 | # - select: 86 | # kind: ValidatingWebhookConfiguration 87 | # fieldPaths: 88 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 89 | # options: 90 | # delimiter: '/' 91 | # index: 1 92 | # create: true 93 | # - select: 94 | # kind: MutatingWebhookConfiguration 95 | # fieldPaths: 96 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 97 | # options: 98 | # delimiter: '/' 99 | # index: 1 100 | # create: true 101 | # - select: 102 | # kind: CustomResourceDefinition 103 | # fieldPaths: 104 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 105 | # options: 106 | # delimiter: '/' 107 | # index: 1 108 | # create: true 109 | # - source: # Add cert-manager annotation to the webhook Service 110 | # kind: Service 111 | # version: v1 112 | # name: webhook-service 113 | # fieldPath: .metadata.name # namespace of the service 114 | # targets: 115 | # - select: 116 | # kind: Certificate 117 | # group: cert-manager.io 118 | # version: v1 119 | # fieldPaths: 120 | # - .spec.dnsNames.0 121 | # - .spec.dnsNames.1 122 | # options: 123 | # delimiter: '.' 124 | # index: 0 125 | # create: true 126 | # - source: 127 | # kind: Service 128 | # version: v1 129 | # name: webhook-service 130 | # fieldPath: .metadata.namespace # namespace of the service 131 | # targets: 132 | # - select: 133 | # kind: Certificate 134 | # group: cert-manager.io 135 | # version: v1 136 | # fieldPaths: 137 | # - .spec.dnsNames.0 138 | # - .spec.dnsNames.1 139 | # options: 140 | # delimiter: '.' 141 | # index: 1 142 | # create: true 143 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the 2 | # controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: kube-rbac-proxy 13 | securityContext: 14 | allowPrivilegeEscalation: false 15 | capabilities: 16 | drop: 17 | - "ALL" 18 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.15.0 19 | args: 20 | - "--secure-listen-address=0.0.0.0:8443" 21 | - "--upstream=http://127.0.0.1:8080/" 22 | - "--logtostderr=true" 23 | - "--v=0" 24 | ports: 25 | - containerPort: 8443 26 | protocol: TCP 27 | name: https 28 | resources: 29 | limits: 30 | cpu: 500m 31 | memory: 128Mi 32 | requests: 33 | cpu: 5m 34 | memory: 64Mi 35 | - name: manager 36 | args: 37 | - "--health-probe-bind-address=:8081" 38 | - "--metrics-bind-address=127.0.0.1:8080" 39 | - "--leader-elect" 40 | -------------------------------------------------------------------------------- /config/default/manager_config_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | apiVersion: kustomize.config.k8s.io/v1beta1 4 | kind: Kustomization 5 | images: 6 | - name: controller 7 | newName: containeroo/cloudflare-operator 8 | newTag: test 9 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: namespace 7 | app.kubernetes.io/instance: system 8 | app.kubernetes.io/component: manager 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: system 13 | --- 14 | apiVersion: apps/v1 15 | kind: Deployment 16 | metadata: 17 | name: controller-manager 18 | namespace: system 19 | labels: 20 | control-plane: controller-manager 21 | app.kubernetes.io/name: deployment 22 | app.kubernetes.io/instance: controller-manager 23 | app.kubernetes.io/component: manager 24 | app.kubernetes.io/created-by: cloudflare-operator 25 | app.kubernetes.io/part-of: cloudflare-operator 26 | app.kubernetes.io/managed-by: kustomize 27 | spec: 28 | selector: 29 | matchLabels: 30 | control-plane: controller-manager 31 | replicas: 1 32 | template: 33 | metadata: 34 | annotations: 35 | kubectl.kubernetes.io/default-container: manager 36 | labels: 37 | control-plane: controller-manager 38 | spec: 39 | # TODO(user): Uncomment the following code to configure the nodeAffinity expression 40 | # according to the platforms which are supported by your solution. 41 | # It is considered best practice to support multiple architectures. You can 42 | # build your manager image using the makefile target docker-buildx. 43 | # affinity: 44 | # nodeAffinity: 45 | # requiredDuringSchedulingIgnoredDuringExecution: 46 | # nodeSelectorTerms: 47 | # - matchExpressions: 48 | # - key: kubernetes.io/arch 49 | # operator: In 50 | # values: 51 | # - amd64 52 | # - arm64 53 | # - ppc64le 54 | # - s390x 55 | # - key: kubernetes.io/os 56 | # operator: In 57 | # values: 58 | # - linux 59 | securityContext: 60 | runAsNonRoot: true 61 | # TODO(user): For common cases that do not require escalating privileges 62 | # it is recommended to ensure that all your Pods/Containers are restrictive. 63 | # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted 64 | # Please uncomment the following code if your project does NOT have to work on old Kubernetes 65 | # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). 66 | # seccompProfile: 67 | # type: RuntimeDefault 68 | containers: 69 | - command: 70 | - /manager 71 | args: 72 | - --leader-elect 73 | image: controller:latest 74 | name: manager 75 | securityContext: 76 | allowPrivilegeEscalation: false 77 | capabilities: 78 | drop: 79 | - "ALL" 80 | livenessProbe: 81 | httpGet: 82 | path: /healthz 83 | port: 8081 84 | initialDelaySeconds: 15 85 | periodSeconds: 20 86 | readinessProbe: 87 | httpGet: 88 | path: /readyz 89 | port: 8081 90 | initialDelaySeconds: 5 91 | periodSeconds: 10 92 | # TODO(user): Configure the resources accordingly based on the project requirements. 93 | # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ 94 | resources: 95 | limits: 96 | cpu: 500m 97 | memory: 128Mi 98 | requests: 99 | cpu: 10m 100 | memory: 64Mi 101 | serviceAccountName: controller-manager 102 | terminationGracePeriodSeconds: 10 103 | -------------------------------------------------------------------------------- /config/manifests/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # These resources constitute the fully configured set of manifests 2 | # used to generate the 'manifests/' directory in a bundle. 3 | resources: 4 | - bases/cloudflare-operator.clusterserviceversion.yaml 5 | - ../default 6 | - ../samples 7 | - ../scorecard 8 | 9 | # [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix. 10 | # Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager. 11 | # These patches remove the unnecessary "cert" volume and its manager container volumeMount. 12 | #patchesJson6902: 13 | #- target: 14 | # group: apps 15 | # version: v1 16 | # kind: Deployment 17 | # name: controller-manager 18 | # namespace: system 19 | # patch: |- 20 | # # Remove the manager container's "cert" volumeMount, since OLM will create and mount a set of certs. 21 | # # Update the indices in this path if adding or removing containers/volumeMounts in the manager's Deployment. 22 | # - op: remove 23 | 24 | # path: /spec/template/spec/containers/0/volumeMounts/0 25 | # # Remove the "cert" volume, since OLM will create and mount a set of certs. 26 | # # Update the indices in this path if adding or removing volumes in the manager's Deployment. 27 | # - op: remove 28 | # path: /spec/template/spec/volumes/0 29 | -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | # Prometheus Monitor Service (Metrics) 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | labels: 6 | control-plane: controller-manager 7 | app.kubernetes.io/name: servicemonitor 8 | app.kubernetes.io/instance: controller-manager-metrics-monitor 9 | app.kubernetes.io/component: metrics 10 | app.kubernetes.io/created-by: cloudflare-operator 11 | app.kubernetes.io/part-of: cloudflare-operator 12 | app.kubernetes.io/managed-by: kustomize 13 | name: controller-manager-metrics-monitor 14 | namespace: system 15 | spec: 16 | endpoints: 17 | - path: /metrics 18 | port: https 19 | scheme: https 20 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 21 | tlsConfig: 22 | insecureSkipVerify: true 23 | selector: 24 | matchLabels: 25 | control-plane: controller-manager 26 | -------------------------------------------------------------------------------- /config/rbac/account_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit accounts. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: account-editor-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: account-editor-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - accounts 18 | verbs: 19 | - create 20 | - delete 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - cloudflare-operator.io 28 | resources: 29 | - accounts/status 30 | verbs: 31 | - get 32 | -------------------------------------------------------------------------------- /config/rbac/account_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view accounts. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: account-viewer-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: account-viewer-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - accounts 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | - apiGroups: 23 | - cloudflare-operator.io 24 | resources: 25 | - accounts/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrole 6 | app.kubernetes.io/instance: metrics-reader 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: cloudflare-operator 9 | app.kubernetes.io/part-of: cloudflare-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: metrics-reader 12 | rules: 13 | - nonResourceURLs: 14 | - "/metrics" 15 | verbs: 16 | - get 17 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrole 6 | app.kubernetes.io/instance: proxy-role 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: cloudflare-operator 9 | app.kubernetes.io/part-of: cloudflare-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: proxy-role 12 | rules: 13 | - apiGroups: 14 | - authentication.k8s.io 15 | resources: 16 | - tokenreviews 17 | verbs: 18 | - create 19 | - apiGroups: 20 | - authorization.k8s.io 21 | resources: 22 | - subjectaccessreviews 23 | verbs: 24 | - create 25 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrolebinding 6 | app.kubernetes.io/instance: proxy-rolebinding 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: cloudflare-operator 9 | app.kubernetes.io/part-of: cloudflare-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: proxy-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: ClusterRole 15 | name: proxy-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | namespace: system 20 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: service 7 | app.kubernetes.io/instance: controller-manager-metrics-service 8 | app.kubernetes.io/component: kube-rbac-proxy 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: controller-manager-metrics-service 13 | namespace: system 14 | spec: 15 | ports: 16 | - name: https 17 | port: 8443 18 | protocol: TCP 19 | targetPort: https 20 | selector: 21 | control-plane: controller-manager 22 | -------------------------------------------------------------------------------- /config/rbac/dnsrecord_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit dnsrecords. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: dnsrecord-editor-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: dnsrecord-editor-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - dnsrecords 18 | verbs: 19 | - create 20 | - delete 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - cloudflare-operator.io 28 | resources: 29 | - dnsrecords/status 30 | verbs: 31 | - get 32 | -------------------------------------------------------------------------------- /config/rbac/dnsrecord_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view dnsrecords. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: dnsrecord-viewer-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: dnsrecord-viewer-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - dnsrecords 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | - apiGroups: 23 | - cloudflare-operator.io 24 | resources: 25 | - dnsrecords/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /config/rbac/ip_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit ips. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: ip-editor-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: ip-editor-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - ips 18 | verbs: 19 | - create 20 | - delete 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - cloudflare-operator.io 28 | resources: 29 | - ips/status 30 | verbs: 31 | - get 32 | -------------------------------------------------------------------------------- /config/rbac/ip_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view ips. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: ip-viewer-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: ip-viewer-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - ips 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | - apiGroups: 23 | - cloudflare-operator.io 24 | resources: 25 | - ips/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # Comment the following 4 lines if you want to disable 13 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 14 | # which protects your /metrics endpoint. 15 | - auth_proxy_service.yaml 16 | - auth_proxy_role.yaml 17 | - auth_proxy_role_binding.yaml 18 | - auth_proxy_client_clusterrole.yaml 19 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: role 7 | app.kubernetes.io/instance: leader-election-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: leader-election-role 13 | rules: 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - configmaps 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | - create 23 | - update 24 | - patch 25 | - delete 26 | - apiGroups: 27 | - coordination.k8s.io 28 | resources: 29 | - leases 30 | verbs: 31 | - get 32 | - list 33 | - watch 34 | - create 35 | - update 36 | - patch 37 | - delete 38 | - apiGroups: 39 | - "" 40 | resources: 41 | - events 42 | verbs: 43 | - create 44 | - patch 45 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: rolebinding 6 | app.kubernetes.io/instance: leader-election-rolebinding 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: cloudflare-operator 9 | app.kubernetes.io/part-of: cloudflare-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: leader-election-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: Role 15 | name: leader-election-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | namespace: system 20 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - secrets 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - cloudflare-operator.io 17 | resources: 18 | - accounts 19 | - dnsrecords 20 | - ips 21 | - zones 22 | verbs: 23 | - create 24 | - delete 25 | - get 26 | - list 27 | - patch 28 | - update 29 | - watch 30 | - apiGroups: 31 | - cloudflare-operator.io 32 | resources: 33 | - accounts/finalizers 34 | - dnsrecords/finalizers 35 | - ips/finalizers 36 | - zones/finalizers 37 | verbs: 38 | - update 39 | - apiGroups: 40 | - cloudflare-operator.io 41 | resources: 42 | - accounts/status 43 | - dnsrecords/status 44 | - ips/status 45 | - zones/status 46 | verbs: 47 | - get 48 | - patch 49 | - update 50 | - apiGroups: 51 | - networking.k8s.io 52 | resources: 53 | - ingresses 54 | verbs: 55 | - get 56 | - list 57 | - watch 58 | - apiGroups: 59 | - networking.k8s.io 60 | resources: 61 | - ingresses/finalizers 62 | verbs: 63 | - update 64 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrolebinding 6 | app.kubernetes.io/instance: manager-rolebinding 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: cloudflare-operator 9 | app.kubernetes.io/part-of: cloudflare-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: manager-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: ClusterRole 15 | name: manager-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | namespace: system 20 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: serviceaccount 6 | app.kubernetes.io/instance: controller-manager-sa 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: cloudflare-operator 9 | app.kubernetes.io/part-of: cloudflare-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/zone_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit zones. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: zone-editor-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: zone-editor-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - zones 18 | verbs: 19 | - create 20 | - delete 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - cloudflare-operator.io 28 | resources: 29 | - zones/status 30 | verbs: 31 | - get 32 | -------------------------------------------------------------------------------- /config/rbac/zone_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view zones. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: clusterrole 7 | app.kubernetes.io/instance: zone-viewer-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: cloudflare-operator 10 | app.kubernetes.io/part-of: cloudflare-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: zone-viewer-role 13 | rules: 14 | - apiGroups: 15 | - cloudflare-operator.io 16 | resources: 17 | - zones 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | - apiGroups: 23 | - cloudflare-operator.io 24 | resources: 25 | - zones/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /config/samples/cloudflareoperatorio_v1_account.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: api-token-sample 6 | namespace: cloudflare-operator-system 7 | type: Opaque 8 | stringData: 9 | apiToken: ${CF_API_TOKEN} 10 | --- 11 | apiVersion: cloudflare-operator.io/v1 12 | kind: Account 13 | metadata: 14 | name: account-sample 15 | spec: 16 | apiToken: 17 | secretRef: 18 | name: api-token-sample 19 | namespace: cloudflare-operator-system 20 | -------------------------------------------------------------------------------- /config/samples/cloudflareoperatorio_v1_dnsrecord.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: cloudflare-operator.io/v1 3 | kind: DNSRecord 4 | metadata: 5 | name: dnsrecord-sample 6 | namespace: cloudflare-operator-system 7 | spec: 8 | name: containeroo-test.org 9 | content: 9.9.9.9 10 | type: A 11 | proxied: false 12 | ttl: 1 13 | --- 14 | apiVersion: cloudflare-operator.io/v1 15 | kind: DNSRecord 16 | metadata: 17 | name: dnsrecord-ip-ref-sample 18 | namespace: cloudflare-operator-system 19 | spec: 20 | name: containeroo-test.org 21 | type: A 22 | ipRef: 23 | name: ip-sample 24 | proxied: false 25 | ttl: 1 26 | -------------------------------------------------------------------------------- /config/samples/cloudflareoperatorio_v1_ip.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: cloudflare-operator.io/v1 3 | kind: IP 4 | metadata: 5 | name: ip-sample 6 | spec: 7 | type: static # or dynamic 8 | address: 1.1.1.1 # will be automatically generated if type is dynamic 9 | # list of services that return your public IP 10 | # ipSources: 11 | # - url: https://ifconfig.me/ip 12 | # - url: https://ipecho.net/plain 13 | # - url: https://myip.is/ip/ 14 | # - url: https://checkip.amazonaws.com 15 | # - url: https://api.ipify.org 16 | interval: 1m # only used if type is dynamic, interval to check if IP has changed 17 | -------------------------------------------------------------------------------- /config/samples/cloudflareoperatorio_v1_zone.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: cloudflare-operator.io/v1 3 | kind: Zone 4 | metadata: 5 | name: zone-sample 6 | spec: 7 | name: containeroo-test.org 8 | prune: false 9 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - cloudflareoperatorio_v1_dnsrecord.yaml 4 | - cloudflareoperatorio_v1_ip.yaml 5 | - cloudflareoperatorio_v1_account.yaml 6 | - cloudflareoperatorio_v1_zone.yaml 7 | #+kubebuilder:scaffold:manifestskustomizesamples 8 | -------------------------------------------------------------------------------- /config/samples/networking_v1_ingress.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: ingress-sample 6 | namespace: cloudflare-operator-system 7 | annotations: 8 | cloudflare-operator.io/content: "144.144.144.144" 9 | spec: 10 | rules: 11 | - host: ingress.containeroo-test.org 12 | http: 13 | paths: 14 | - path: / 15 | pathType: Prefix 16 | backend: 17 | service: 18 | name: service1 19 | port: 20 | number: 80 21 | -------------------------------------------------------------------------------- /config/scorecard/bases/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: scorecard.operatorframework.io/v1alpha3 2 | kind: Configuration 3 | metadata: 4 | name: config 5 | stages: 6 | - parallel: true 7 | tests: [] 8 | -------------------------------------------------------------------------------- /config/scorecard/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - bases/config.yaml 3 | patchesJson6902: 4 | - path: patches/basic.config.yaml 5 | target: 6 | group: scorecard.operatorframework.io 7 | version: v1alpha3 8 | kind: Configuration 9 | name: config 10 | - path: patches/olm.config.yaml 11 | target: 12 | group: scorecard.operatorframework.io 13 | version: v1alpha3 14 | kind: Configuration 15 | name: config 16 | #+kubebuilder:scaffold:patchesJson6902 17 | -------------------------------------------------------------------------------- /config/scorecard/patches/basic.config.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /stages/0/tests/- 3 | value: 4 | entrypoint: 5 | - scorecard-test 6 | - basic-check-spec 7 | image: quay.io/operator-framework/scorecard-test:v1.34.2 8 | labels: 9 | suite: basic 10 | test: basic-check-spec-test 11 | -------------------------------------------------------------------------------- /config/scorecard/patches/olm.config.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /stages/0/tests/- 3 | value: 4 | entrypoint: 5 | - scorecard-test 6 | - olm-bundle-validation 7 | image: quay.io/operator-framework/scorecard-test:v1.34.2 8 | labels: 9 | suite: olm 10 | test: olm-bundle-validation-test 11 | - op: add 12 | path: /stages/0/tests/- 13 | value: 14 | entrypoint: 15 | - scorecard-test 16 | - olm-crds-have-validation 17 | image: quay.io/operator-framework/scorecard-test:v1.34.2 18 | labels: 19 | suite: olm 20 | test: olm-crds-have-validation-test 21 | - op: add 22 | path: /stages/0/tests/- 23 | value: 24 | entrypoint: 25 | - scorecard-test 26 | - olm-crds-have-resources 27 | image: quay.io/operator-framework/scorecard-test:v1.34.2 28 | labels: 29 | suite: olm 30 | test: olm-crds-have-resources-test 31 | - op: add 32 | path: /stages/0/tests/- 33 | value: 34 | entrypoint: 35 | - scorecard-test 36 | - olm-spec-descriptors 37 | image: quay.io/operator-framework/scorecard-test:v1.34.2 38 | labels: 39 | suite: olm 40 | test: olm-spec-descriptors-test 41 | - op: add 42 | path: /stages/0/tests/- 43 | value: 44 | entrypoint: 45 | - scorecard-test 46 | - olm-status-descriptors 47 | image: quay.io/operator-framework/scorecard-test:v1.34.2 48 | labels: 49 | suite: olm 50 | test: olm-status-descriptors-test 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/containeroo/cloudflare-operator 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/cloudflare/cloudflare-go v0.115.0 7 | github.com/fluxcd/pkg/runtime v0.58.0 8 | github.com/itchyny/gojq v0.12.17 9 | github.com/onsi/ginkgo/v2 v2.23.4 10 | github.com/onsi/gomega v1.37.0 11 | github.com/prometheus/client_golang v1.22.0 12 | golang.org/x/net v0.39.0 13 | k8s.io/api v0.32.3 14 | k8s.io/apiextensions-apiserver v0.32.3 15 | k8s.io/apimachinery v0.32.3 16 | k8s.io/client-go v0.32.3 17 | sigs.k8s.io/controller-runtime v0.20.4 18 | ) 19 | 20 | require ( 21 | github.com/beorn7/perks v1.0.1 // indirect 22 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/emicklei/go-restful/v3 v3.12.1 // indirect 25 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 26 | github.com/fluxcd/pkg/apis/meta v1.10.0 // indirect 27 | github.com/fsnotify/fsnotify v1.8.0 // indirect 28 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 29 | github.com/go-logr/logr v1.4.2 // indirect 30 | github.com/go-logr/zapr v1.3.0 // indirect 31 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 32 | github.com/go-openapi/jsonreference v0.21.0 // indirect 33 | github.com/go-openapi/swag v0.23.0 // indirect 34 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 35 | github.com/goccy/go-json v0.10.5 // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/golang/protobuf v1.5.4 // indirect 38 | github.com/google/btree v1.1.3 // indirect 39 | github.com/google/gnostic-models v0.6.9 // indirect 40 | github.com/google/go-cmp v0.7.0 // indirect 41 | github.com/google/go-querystring v1.1.0 // indirect 42 | github.com/google/gofuzz v1.2.0 // indirect 43 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/itchyny/timefmt-go v0.1.6 // indirect 46 | github.com/josharian/intern v1.0.0 // indirect 47 | github.com/json-iterator/go v1.1.12 // indirect 48 | github.com/mailru/easyjson v0.9.0 // indirect 49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 50 | github.com/modern-go/reflect2 v1.0.2 // indirect 51 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 52 | github.com/pkg/errors v0.9.1 // indirect 53 | github.com/prometheus/client_model v0.6.1 // indirect 54 | github.com/prometheus/common v0.62.0 // indirect 55 | github.com/prometheus/procfs v0.15.1 // indirect 56 | github.com/spf13/pflag v1.0.6 // indirect 57 | github.com/x448/float16 v0.8.4 // indirect 58 | go.uber.org/automaxprocs v1.6.0 // indirect 59 | go.uber.org/multierr v1.11.0 // indirect 60 | go.uber.org/zap v1.27.0 // indirect 61 | golang.org/x/oauth2 v0.27.0 // indirect 62 | golang.org/x/sync v0.13.0 // indirect 63 | golang.org/x/sys v0.32.0 // indirect 64 | golang.org/x/term v0.31.0 // indirect 65 | golang.org/x/text v0.24.0 // indirect 66 | golang.org/x/time v0.10.0 // indirect 67 | golang.org/x/tools v0.31.0 // indirect 68 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 69 | google.golang.org/protobuf v1.36.5 // indirect 70 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 71 | gopkg.in/inf.v0 v0.9.1 // indirect 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | k8s.io/klog/v2 v2.130.1 // indirect 74 | k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect 75 | k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect 76 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 77 | sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect 78 | sigs.k8s.io/yaml v1.4.0 // indirect 79 | ) 80 | -------------------------------------------------------------------------------- /hack/api-docs/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "hideMemberFields": ["TypeMeta"], 3 | "hideTypePatterns": ["ParseError$", "List$"], 4 | "externalPackages": [ 5 | { 6 | "typeMatchPrefix": "^k8s\\.io/apimachinery/pkg/apis/meta/v1\\.Duration$", 7 | "docsURLTemplate": "https://godoc.org/k8s.io/apimachinery/pkg/apis/meta/v1#Duration" 8 | }, 9 | { 10 | "typeMatchPrefix": "^k8s\\.io/apiextensions-apiserver/pkg/apis/apiextensions/v1\\.JSON$", 11 | "docsURLTemplate": "https://pkg.go.dev/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1?tab=doc#JSON" 12 | }, 13 | { 14 | "typeMatchPrefix": "^k8s\\.io/(api|apimachinery/pkg/apis)/", 15 | "docsURLTemplate": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#{{lower .TypeIdentifier}}-{{arrIndex .PackageSegments -1}}-{{arrIndex .PackageSegments -2}}" 16 | }, 17 | { 18 | "typeMatchPrefix": "^k8s\\.io/apimachinery/pkg/apis/meta/v1\\.Condition$", 19 | "docsURLTemplate": "https://godoc.org/k8s.io/apimachinery/pkg/apis/meta/v1#Condition" 20 | } 21 | ], 22 | "typeDisplayNamePrefixOverrides": { 23 | "k8s.io/api/": "Kubernetes ", 24 | "k8s.io/apimachinery/pkg/apis/": "Kubernetes ", 25 | "k8s.io/apiextensions-apiserver/": "Kubernetes " 26 | }, 27 | "markdownDisabled": false 28 | } 29 | -------------------------------------------------------------------------------- /hack/api-docs/template/members.tpl: -------------------------------------------------------------------------------- 1 | {{ define "members" }} 2 | {{ range .Members }} 3 | {{ if not (hiddenMember .)}} 4 | 5 | 6 | {{ fieldName . }}
7 | 8 | {{ if linkForType .Type }} 9 | 10 | {{ typeDisplayName .Type }} 11 | 12 | {{ else }} 13 | {{ typeDisplayName .Type }} 14 | {{ end }} 15 | 16 | 17 | 18 | {{ if fieldEmbedded . }} 19 |

20 | (Members of {{ fieldName . }} are embedded into this type.) 21 |

22 | {{ end}} 23 | 24 | {{ if isOptionalMember .}} 25 | (Optional) 26 | {{ end }} 27 | 28 | {{ safe (renderComments .CommentLines) }} 29 | 30 | {{ if and (eq (.Type.Name.Name) "ObjectMeta") }} 31 | Refer to the Kubernetes API documentation for the fields of the 32 | metadata field. 33 | {{ end }} 34 | 35 | {{ if or (eq (fieldName .) "spec") }} 36 |
37 |
38 | 39 | {{ template "members" .Type }} 40 |
41 | {{ end }} 42 | 43 | 44 | {{ end }} 45 | {{ end }} 46 | {{ end }} 47 | -------------------------------------------------------------------------------- /hack/api-docs/template/pkg.tpl: -------------------------------------------------------------------------------- 1 | {{ define "packages" }} 2 | --- 3 | title: "API Reference" 4 | description: "cloudflare-operator API Reference" 5 | weight: 50 6 | --- 7 | 8 | {{ with .packages}} 9 |

Packages:

10 | 17 | {{ end}} 18 | 19 | {{ range .packages }} 20 |

21 | {{- packageDisplayName . -}} 22 |

23 | 24 | {{ with (index .GoPackages 0 )}} 25 | {{ with .DocComments }} 26 | {{ safe (renderComments .) }} 27 | {{ end }} 28 | {{ end }} 29 | 30 | Resource Types: 31 | 32 | 41 | 42 | {{ range (visibleTypes (sortedTypes .Types))}} 43 | {{ template "type" . }} 44 | {{ end }} 45 | {{ end }} 46 | 47 |
48 |

This page was automatically generated with gen-crd-api-reference-docs

49 |
50 | {{ end }} 51 | -------------------------------------------------------------------------------- /hack/api-docs/template/type.tpl: -------------------------------------------------------------------------------- 1 | {{ define "type" }} 2 |

3 | {{- .Name.Name }} 4 | {{ if eq .Kind "Alias" }}({{.Underlying}} alias){{ end -}} 5 |

6 | 7 | {{ with (typeReferences .) }} 8 |

9 | (Appears on: 10 | {{- $prev := "" -}} 11 | {{- range . -}} 12 | {{- if $prev -}}, {{ end -}} 13 | {{ $prev = . }} 14 | {{ typeDisplayName . }} 15 | {{- end -}} 16 | ) 17 |

18 | {{ end }} 19 | 20 | {{ with .CommentLines }} 21 | {{ safe (renderComments .) }} 22 | {{ end }} 23 | 24 | {{ if .Members }} 25 |
26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{ if isExportedType . }} 36 | 37 | 40 | 43 | 44 | 45 | 49 | 52 | 53 | {{ end }} 54 | {{ template "members" . }} 55 | 56 |
FieldDescription
38 | apiVersion
39 | string
41 | {{ apiGroup . }} 42 |
46 | kind
47 | string 48 |
50 | {{ .Name.Name }} 51 |
57 |
58 |
59 | {{ end }} 60 | {{ end }} 61 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /internal/conditions/conditions.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package conditions 18 | 19 | import ( 20 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 21 | "github.com/containeroo/cloudflare-operator/internal/metrics" 22 | "github.com/fluxcd/pkg/runtime/conditions" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | ) 25 | 26 | // SetCondition updates the Kubernetes condition status dynamically 27 | func SetCondition(to conditions.Setter, status metav1.ConditionStatus, reason, msg string) { 28 | conditions.Set(to, &metav1.Condition{ 29 | Type: cloudflareoperatoriov1.ConditionTypeReady, 30 | Status: status, 31 | Reason: reason, 32 | Message: msg, 33 | }) 34 | 35 | updateMetrics(to, status) 36 | } 37 | 38 | // updateMetrics handles updating the failure counters for each type 39 | func updateMetrics(to conditions.Setter, status metav1.ConditionStatus) { 40 | value := 0.0 41 | if status == metav1.ConditionFalse { 42 | value = 1.0 43 | } 44 | 45 | switch o := to.(type) { 46 | case *cloudflareoperatoriov1.Account: 47 | metrics.AccountFailureCounter.WithLabelValues(o.Name).Set(value) 48 | 49 | case *cloudflareoperatoriov1.Zone: 50 | metrics.ZoneFailureCounter.WithLabelValues(o.Name, o.Spec.Name).Set(value) 51 | 52 | case *cloudflareoperatoriov1.IP: 53 | metrics.IpFailureCounter.WithLabelValues(o.Name, o.Spec.Type).Set(value) 54 | 55 | case *cloudflareoperatoriov1.DNSRecord: 56 | metrics.DnsRecordFailureCounter.WithLabelValues(o.Namespace, o.Name, o.Spec.Name).Set(value) 57 | } 58 | } 59 | 60 | // Convenience wrappers 61 | func MarkFalse(to conditions.Setter, err error) { 62 | SetCondition(to, metav1.ConditionFalse, cloudflareoperatoriov1.ConditionReasonFailed, err.Error()) 63 | } 64 | 65 | func MarkTrue(to conditions.Setter, msg string) { 66 | SetCondition(to, metav1.ConditionTrue, cloudflareoperatoriov1.ConditionReasonReady, msg) 67 | } 68 | 69 | func MarkUnknown(to conditions.Setter, msg string) { 70 | SetCondition(to, metav1.ConditionUnknown, cloudflareoperatoriov1.ConditionReasonNotReady, msg) 71 | } 72 | -------------------------------------------------------------------------------- /internal/conditions/conditions_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package conditions 18 | 19 | import ( 20 | "errors" 21 | "testing" 22 | 23 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 24 | "github.com/fluxcd/pkg/runtime/conditions" 25 | . "github.com/onsi/gomega" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | ) 28 | 29 | func TestPredicate(t *testing.T) { 30 | t.Run("set true condition", func(t *testing.T) { 31 | g := NewWithT(t) 32 | 33 | testAccount := &cloudflareoperatoriov1.Account{} 34 | 35 | MarkTrue(testAccount, "test") 36 | 37 | g.Expect(testAccount.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 38 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "test"), 39 | })) 40 | }) 41 | 42 | t.Run("set false condition", func(t *testing.T) { 43 | g := NewWithT(t) 44 | 45 | testAccount := &cloudflareoperatoriov1.Account{} 46 | 47 | MarkFalse(testAccount, errors.New("test")) 48 | 49 | g.Expect(testAccount.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 50 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "test"), 51 | })) 52 | }) 53 | 54 | t.Run("set unknown condition", func(t *testing.T) { 55 | g := NewWithT(t) 56 | 57 | testAccount := &cloudflareoperatoriov1.Account{} 58 | 59 | MarkUnknown(testAccount, "test") 60 | 61 | g.Expect(testAccount.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 62 | *conditions.UnknownCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonNotReady, "test"), 63 | })) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /internal/controller/account_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "time" 23 | 24 | "sigs.k8s.io/controller-runtime/pkg/builder" 25 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 26 | "sigs.k8s.io/controller-runtime/pkg/predicate" 27 | 28 | "github.com/cloudflare/cloudflare-go" 29 | "github.com/fluxcd/pkg/runtime/patch" 30 | corev1 "k8s.io/api/core/v1" 31 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 32 | 33 | "k8s.io/apimachinery/pkg/runtime" 34 | ctrl "sigs.k8s.io/controller-runtime" 35 | "sigs.k8s.io/controller-runtime/pkg/client" 36 | 37 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 38 | intconditions "github.com/containeroo/cloudflare-operator/internal/conditions" 39 | "github.com/containeroo/cloudflare-operator/internal/metrics" 40 | apierrors "k8s.io/apimachinery/pkg/api/errors" 41 | apierrutil "k8s.io/apimachinery/pkg/util/errors" 42 | ) 43 | 44 | // AccountReconciler reconciles an Account object 45 | type AccountReconciler struct { 46 | client.Client 47 | Scheme *runtime.Scheme 48 | 49 | RetryInterval time.Duration 50 | 51 | CloudflareAPI *cloudflare.API 52 | } 53 | 54 | var errWaitForAccount = errors.New("must wait for account") 55 | 56 | // SetupWithManager sets up the controller with the Manager. 57 | func (r *AccountReconciler) SetupWithManager(mgr ctrl.Manager) error { 58 | return ctrl.NewControllerManagedBy(mgr). 59 | For(&cloudflareoperatoriov1.Account{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 60 | Complete(r) 61 | } 62 | 63 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=accounts,verbs=get;list;watch;create;update;patch;delete 64 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=accounts/status,verbs=get;update;patch 65 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=accounts/finalizers,verbs=update 66 | // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch 67 | 68 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 69 | // move the current state of the cluster closer to the desired state. 70 | func (r *AccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { 71 | account := &cloudflareoperatoriov1.Account{} 72 | if err := r.Get(ctx, req.NamespacedName, account); err != nil { 73 | return ctrl.Result{}, client.IgnoreNotFound(err) 74 | } 75 | 76 | patchHelper := patch.NewSerialPatcher(account, r.Client) 77 | 78 | defer func() { 79 | patchOpts := []patch.Option{} 80 | 81 | if errors.Is(retErr, reconcile.TerminalError(nil)) || (retErr == nil && (result.IsZero() || !result.Requeue)) { 82 | patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) 83 | } 84 | 85 | if err := patchHelper.Patch(ctx, account, patchOpts...); err != nil { 86 | if !account.DeletionTimestamp.IsZero() { 87 | err = apierrutil.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) 88 | } 89 | retErr = apierrutil.Reduce(apierrutil.NewAggregate([]error{retErr, err})) 90 | } 91 | }() 92 | 93 | if !account.DeletionTimestamp.IsZero() { 94 | r.reconcileDelete(account) 95 | return ctrl.Result{}, nil 96 | } 97 | 98 | if !controllerutil.ContainsFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer) { 99 | controllerutil.AddFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer) 100 | return ctrl.Result{Requeue: true}, nil 101 | } 102 | 103 | return r.reconcileAccount(ctx, account) 104 | } 105 | 106 | // reconcileAccount reconciles the account 107 | func (r *AccountReconciler) reconcileAccount(ctx context.Context, account *cloudflareoperatoriov1.Account) (ctrl.Result, error) { 108 | secret := &corev1.Secret{} 109 | if err := r.Get(ctx, client.ObjectKey{ 110 | Namespace: account.Spec.ApiToken.SecretRef.Namespace, 111 | Name: account.Spec.ApiToken.SecretRef.Name, 112 | }, secret); err != nil { 113 | intconditions.MarkFalse(account, err) 114 | if apierrors.IsNotFound(err) { 115 | return ctrl.Result{RequeueAfter: r.RetryInterval}, nil 116 | } 117 | return ctrl.Result{}, err 118 | } 119 | 120 | cloudflareAPIToken := string(secret.Data["apiToken"]) 121 | if cloudflareAPIToken == "" { 122 | intconditions.MarkFalse(account, errors.New("secret has no key named \"apiToken\"")) 123 | return ctrl.Result{RequeueAfter: r.RetryInterval}, nil 124 | } 125 | 126 | if r.CloudflareAPI.APIToken != cloudflareAPIToken { 127 | cloudflareAPI, err := cloudflare.NewWithAPIToken(cloudflareAPIToken) 128 | if err != nil { 129 | intconditions.MarkFalse(account, err) 130 | return ctrl.Result{}, err 131 | } 132 | 133 | *r.CloudflareAPI = *cloudflareAPI 134 | } 135 | 136 | intconditions.MarkTrue(account, "Account is ready") 137 | 138 | return ctrl.Result{RequeueAfter: account.Spec.Interval.Duration}, nil 139 | } 140 | 141 | // reconcileDelete reconciles the deletion of the account 142 | func (r *AccountReconciler) reconcileDelete(account *cloudflareoperatoriov1.Account) { 143 | metrics.AccountFailureCounter.DeleteLabelValues(account.Name) 144 | controllerutil.RemoveFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer) 145 | } 146 | -------------------------------------------------------------------------------- /internal/controller/account_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "testing" 23 | 24 | "github.com/fluxcd/pkg/runtime/conditions" 25 | . "github.com/onsi/gomega" 26 | corev1 "k8s.io/api/core/v1" 27 | 28 | "k8s.io/apimachinery/pkg/runtime" 29 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 30 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 31 | 32 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 33 | 34 | "github.com/cloudflare/cloudflare-go" 35 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 36 | networkingv1 "k8s.io/api/networking/v1" 37 | ) 38 | 39 | func NewTestScheme() *runtime.Scheme { 40 | s := runtime.NewScheme() 41 | utilruntime.Must(corev1.AddToScheme(s)) 42 | utilruntime.Must(cloudflareoperatoriov1.AddToScheme(s)) 43 | utilruntime.Must(networkingv1.AddToScheme(s)) 44 | return s 45 | } 46 | 47 | var cloudflareAPI cloudflare.API 48 | 49 | func TestAccountReconciler_reconcileAccount(t *testing.T) { 50 | t.Run("reconcile account", func(t *testing.T) { 51 | g := NewWithT(t) 52 | 53 | secret := &corev1.Secret{ 54 | ObjectMeta: metav1.ObjectMeta{ 55 | Name: "secret", 56 | Namespace: "default", 57 | }, 58 | Data: map[string][]byte{ 59 | "apiToken": []byte(os.Getenv("CF_API_TOKEN")), 60 | }, 61 | } 62 | 63 | account := &cloudflareoperatoriov1.Account{ 64 | ObjectMeta: metav1.ObjectMeta{ 65 | Name: "account", 66 | }, 67 | Spec: cloudflareoperatoriov1.AccountSpec{ 68 | ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ 69 | SecretRef: corev1.SecretReference{ 70 | Name: "secret", 71 | Namespace: "default", 72 | }, 73 | }, 74 | }, 75 | } 76 | 77 | r := &AccountReconciler{ 78 | Client: fake.NewClientBuilder(). 79 | WithScheme(NewTestScheme()). 80 | WithObjects(secret, account). 81 | Build(), 82 | CloudflareAPI: &cloudflareAPI, 83 | } 84 | 85 | _, err := r.reconcileAccount(context.TODO(), account) 86 | g.Expect(err).ToNot(HaveOccurred()) 87 | 88 | g.Expect(account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 89 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "Account is ready"), 90 | })) 91 | 92 | g.Expect(cloudflareAPI.APIToken).To(Equal(string(secret.Data["apiToken"]))) 93 | }) 94 | 95 | t.Run("econcile account error secret not found", func(t *testing.T) { 96 | g := NewWithT(t) 97 | 98 | account := &cloudflareoperatoriov1.Account{ 99 | ObjectMeta: metav1.ObjectMeta{ 100 | Name: "account", 101 | }, 102 | Spec: cloudflareoperatoriov1.AccountSpec{ 103 | ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ 104 | SecretRef: corev1.SecretReference{ 105 | Name: "secret", 106 | Namespace: "default", 107 | }, 108 | }, 109 | }, 110 | } 111 | 112 | r := &AccountReconciler{ 113 | Client: fake.NewClientBuilder(). 114 | WithScheme(NewTestScheme()). 115 | WithObjects(account). 116 | Build(), 117 | CloudflareAPI: &cloudflareAPI, 118 | } 119 | 120 | _, err := r.reconcileAccount(context.TODO(), account) 121 | g.Expect(err).ToNot(HaveOccurred()) 122 | 123 | g.Expect(account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 124 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "secrets \"secret\" not found"), 125 | })) 126 | }) 127 | 128 | t.Run("reconcile account error key not found in secret", func(t *testing.T) { 129 | g := NewWithT(t) 130 | 131 | secret := &corev1.Secret{ 132 | ObjectMeta: metav1.ObjectMeta{ 133 | Name: "secret", 134 | Namespace: "default", 135 | }, 136 | Data: map[string][]byte{ 137 | "invalid": []byte("invalid"), 138 | }, 139 | } 140 | 141 | account := &cloudflareoperatoriov1.Account{ 142 | ObjectMeta: metav1.ObjectMeta{ 143 | Name: "account", 144 | }, 145 | Spec: cloudflareoperatoriov1.AccountSpec{ 146 | ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ 147 | SecretRef: corev1.SecretReference{ 148 | Name: "secret", 149 | Namespace: "default", 150 | }, 151 | }, 152 | }, 153 | } 154 | 155 | r := &AccountReconciler{ 156 | Client: fake.NewClientBuilder(). 157 | WithScheme(NewTestScheme()). 158 | WithObjects(secret, account). 159 | Build(), 160 | CloudflareAPI: &cloudflareAPI, 161 | } 162 | 163 | _, err := r.reconcileAccount(context.TODO(), account) 164 | g.Expect(err).ToNot(HaveOccurred()) 165 | 166 | g.Expect(account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 167 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "secret has no key named \"apiToken\""), 168 | })) 169 | }) 170 | } 171 | -------------------------------------------------------------------------------- /internal/controller/dnsrecord_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "testing" 23 | 24 | "github.com/cloudflare/cloudflare-go" 25 | "github.com/fluxcd/pkg/runtime/conditions" 26 | . "github.com/onsi/gomega" 27 | 28 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 29 | 30 | v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 31 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 32 | 33 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 34 | ) 35 | 36 | func TestDNSRecordReconciler_reconcileDNSRecord(t *testing.T) { 37 | zone := &cloudflareoperatoriov1.Zone{ 38 | ObjectMeta: metav1.ObjectMeta{ 39 | Name: "zone", 40 | }, 41 | Spec: cloudflareoperatoriov1.ZoneSpec{ 42 | Name: "containeroo-test.org", 43 | }, 44 | Status: cloudflareoperatoriov1.ZoneStatus{ 45 | ID: os.Getenv("CF_ZONE_ID"), 46 | Conditions: []metav1.Condition{{ 47 | Type: cloudflareoperatoriov1.ConditionTypeReady, 48 | Status: metav1.ConditionTrue, 49 | Reason: cloudflareoperatoriov1.ConditionReasonReady, 50 | Message: "Zone is ready", 51 | }}, 52 | }, 53 | } 54 | 55 | dnsRecord := &cloudflareoperatoriov1.DNSRecord{ 56 | ObjectMeta: metav1.ObjectMeta{ 57 | Name: "dnsrecord", 58 | Namespace: "default", 59 | }, 60 | } 61 | 62 | ip := &cloudflareoperatoriov1.IP{ 63 | ObjectMeta: metav1.ObjectMeta{ 64 | Name: "ip", 65 | }, 66 | Spec: cloudflareoperatoriov1.IPSpec{ 67 | Address: "2.2.2.2", 68 | }, 69 | } 70 | 71 | r := &DNSRecordReconciler{ 72 | Client: fake.NewClientBuilder(). 73 | WithScheme(NewTestScheme()). 74 | WithObjects(dnsRecord, ip). 75 | Build(), 76 | CloudflareAPI: &cloudflareAPI, 77 | } 78 | 79 | t.Run("reconcile dnsrecord", func(t *testing.T) { 80 | g := NewWithT(t) 81 | dnsRecord.Spec = cloudflareoperatoriov1.DNSRecordSpec{ 82 | Name: "dnstest.containeroo-test.org", 83 | Content: "1.1.1.1", 84 | Type: "A", 85 | Proxied: new(bool), 86 | } 87 | 88 | _, err := r.reconcileDNSRecord(context.TODO(), dnsRecord, zone) 89 | g.Expect(err).ToNot(HaveOccurred()) 90 | 91 | g.Expect(dnsRecord.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 92 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "DNS record synced"), 93 | })) 94 | 95 | cloudflareDNSRecord, err := cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), dnsRecord.Status.RecordID) 96 | g.Expect(err).ToNot(HaveOccurred()) 97 | 98 | g.Expect(dnsRecord.Status.RecordID).To(Equal(cloudflareDNSRecord.ID)) 99 | 100 | _ = r.reconcileDelete(context.TODO(), zone.Status.ID, dnsRecord) 101 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), dnsRecord.Status.RecordID) 102 | g.Expect(err.Error()).To(ContainSubstring("Record does not exist")) 103 | }) 104 | 105 | t.Run("reconcile dnsrecord with ipref", func(t *testing.T) { 106 | g := NewWithT(t) 107 | dnsRecord.Status = cloudflareoperatoriov1.DNSRecordStatus{} 108 | dnsRecord.Spec = cloudflareoperatoriov1.DNSRecordSpec{ 109 | Name: "dnstest.containeroo-test.org", 110 | Type: "A", 111 | Proxied: new(bool), 112 | IPRef: cloudflareoperatoriov1.DNSRecordSpecIPRef{ 113 | Name: "ip", 114 | }, 115 | } 116 | 117 | _, err := r.reconcileDNSRecord(context.TODO(), dnsRecord, zone) 118 | g.Expect(err).ToNot(HaveOccurred()) 119 | 120 | g.Expect(dnsRecord.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 121 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "DNS record synced"), 122 | })) 123 | 124 | cloudflareDNSRecord, err := cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), dnsRecord.Status.RecordID) 125 | g.Expect(err).ToNot(HaveOccurred()) 126 | 127 | g.Expect(dnsRecord.Status.RecordID).To(Equal(cloudflareDNSRecord.ID)) 128 | g.Expect(cloudflareDNSRecord.Content).To(Equal(ip.Spec.Address)) 129 | 130 | _ = r.reconcileDelete(context.TODO(), zone.Status.ID, dnsRecord) 131 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), dnsRecord.Status.RecordID) 132 | g.Expect(err.Error()).To(ContainSubstring("Record does not exist")) 133 | }) 134 | 135 | t.Run("adopt existing dns record", func(t *testing.T) { 136 | g := NewWithT(t) 137 | cloudflareDNSRecord, err := cloudflareAPI.CreateDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), cloudflare.CreateDNSRecordParams{ 138 | Name: "adopt.containeroo-test.org", 139 | Type: "A", 140 | Content: "1.1.1.1", 141 | Proxied: new(bool), 142 | }) 143 | g.Expect(err).ToNot(HaveOccurred()) 144 | 145 | dnsRecord.Status = cloudflareoperatoriov1.DNSRecordStatus{} 146 | dnsRecord.Spec = cloudflareoperatoriov1.DNSRecordSpec{ 147 | Name: cloudflareDNSRecord.Name, 148 | Type: cloudflareDNSRecord.Type, 149 | Content: cloudflareDNSRecord.Content, 150 | Proxied: cloudflareDNSRecord.Proxied, 151 | } 152 | 153 | _, err = r.reconcileDNSRecord(context.TODO(), dnsRecord, zone) 154 | g.Expect(err).ToNot(HaveOccurred()) 155 | 156 | g.Expect(dnsRecord.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 157 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "DNS record synced"), 158 | })) 159 | 160 | g.Expect(dnsRecord.Status.RecordID).To(Equal(cloudflareDNSRecord.ID)) 161 | 162 | _ = r.reconcileDelete(context.TODO(), zone.Status.ID, dnsRecord) 163 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), dnsRecord.Status.RecordID) 164 | g.Expect(err.Error()).To(ContainSubstring("Record does not exist")) 165 | }) 166 | 167 | t.Run("compare dns record", func(t *testing.T) { 168 | g := NewWithT(t) 169 | 170 | dnsRecordSpec := cloudflareoperatoriov1.DNSRecordSpec{ 171 | Name: "dnstest.containeroo-test.org", 172 | Type: "A", 173 | Content: "1.1.1.1", 174 | Proxied: &[]bool{true}[0], 175 | Priority: &[]uint16{10}[0], 176 | Data: &v1.JSON{ 177 | Raw: []byte(`{"key":"value"}`), 178 | }, 179 | } 180 | 181 | cloudflareDNSRecord := cloudflare.DNSRecord{ 182 | Name: dnsRecordSpec.Name, 183 | Type: dnsRecordSpec.Type, 184 | Content: dnsRecordSpec.Content, 185 | Proxied: dnsRecordSpec.Proxied, 186 | Priority: dnsRecordSpec.Priority, 187 | Data: map[string]any{"key": "value"}, 188 | } 189 | 190 | isEqual := r.compareDNSRecord(dnsRecordSpec, cloudflareDNSRecord) 191 | g.Expect(isEqual).To(BeTrue()) 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /internal/controller/ingress_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "reflect" 23 | "strconv" 24 | "strings" 25 | "time" 26 | 27 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 28 | intpredicates "github.com/containeroo/cloudflare-operator/internal/predicates" 29 | networkingv1 "k8s.io/api/networking/v1" 30 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 31 | "k8s.io/apimachinery/pkg/runtime" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/builder" 34 | "sigs.k8s.io/controller-runtime/pkg/client" 35 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 36 | "sigs.k8s.io/controller-runtime/pkg/predicate" 37 | ) 38 | 39 | // IngressReconciler reconciles an Ingress object 40 | type IngressReconciler struct { 41 | client.Client 42 | Scheme *runtime.Scheme 43 | 44 | RetryInterval time.Duration 45 | DefaultReconcileInterval time.Duration 46 | } 47 | 48 | // SetupWithManager sets up the controller with the Manager. 49 | func (r *IngressReconciler) SetupWithManager(mgr ctrl.Manager) error { 50 | return ctrl.NewControllerManagedBy(mgr). 51 | For(&networkingv1.Ingress{}, builder.WithPredicates(intpredicates.DNSFromIngressPredicate{})). 52 | Owns(&cloudflareoperatoriov1.DNSRecord{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 53 | Complete(r) 54 | } 55 | 56 | // +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch 57 | // +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses/finalizers,verbs=update 58 | 59 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 60 | // move the current state of the cluster closer to the desired state. 61 | func (r *IngressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 62 | ingress := &networkingv1.Ingress{} 63 | if err := r.Get(ctx, req.NamespacedName, ingress); err != nil { 64 | return ctrl.Result{}, client.IgnoreNotFound(err) 65 | } 66 | 67 | return r.reconcileIngress(ctx, ingress) 68 | } 69 | 70 | // reconcileIngress reconciles the ingress 71 | func (r *IngressReconciler) reconcileIngress(ctx context.Context, ingress *networkingv1.Ingress) (ctrl.Result, error) { 72 | log := ctrl.LoggerFrom(ctx) 73 | 74 | dnsRecords, err := r.getDNSRecords(ctx, ingress) 75 | if err != nil { 76 | log.Error(err, "Failed to list DNSRecords") 77 | return ctrl.Result{RequeueAfter: r.RetryInterval}, nil 78 | } 79 | 80 | annotations := ingress.GetAnnotations() 81 | 82 | if annotations["cloudflare-operator.io/content"] == "" && annotations["cloudflare-operator.io/ip-ref"] == "" { 83 | for _, record := range dnsRecords.Items { 84 | if err := r.Delete(ctx, &record); err != nil { 85 | log.Error(err, "Failed to delete DNSRecord", "name", record.Name) 86 | } 87 | } 88 | 89 | return ctrl.Result{}, nil 90 | } 91 | 92 | dnsRecordSpec := r.parseAnnotations(annotations) 93 | existingRecords := make(map[string]cloudflareoperatoriov1.DNSRecord) 94 | for _, record := range dnsRecords.Items { 95 | existingRecords[record.Spec.Name] = record 96 | } 97 | 98 | ingressHosts := r.getIngressHosts(ingress) 99 | 100 | if err := r.reconcileDNSRecords(ctx, ingress, dnsRecordSpec, existingRecords, ingressHosts); err != nil { 101 | log.Error(err, "Failed to reconcile DNS records") 102 | return ctrl.Result{RequeueAfter: r.RetryInterval}, nil 103 | } 104 | 105 | return ctrl.Result{}, nil 106 | } 107 | 108 | // getDNSRecords returns a list of DNSRecords 109 | func (r *IngressReconciler) getDNSRecords(ctx context.Context, ingress *networkingv1.Ingress) (*cloudflareoperatoriov1.DNSRecordList, error) { 110 | dnsRecords := &cloudflareoperatoriov1.DNSRecordList{} 111 | err := r.List(ctx, dnsRecords, client.InNamespace(ingress.Namespace), client.MatchingFields{cloudflareoperatoriov1.OwnerRefUIDIndexKey: string(ingress.UID)}) 112 | return dnsRecords, err 113 | } 114 | 115 | // getIngressHosts returns a map of hosts from the ingress rules 116 | func (r *IngressReconciler) getIngressHosts(ingress *networkingv1.Ingress) map[string]struct{} { 117 | hosts := make(map[string]struct{}) 118 | for _, rule := range ingress.Spec.Rules { 119 | if rule.Host != "" { 120 | hosts[rule.Host] = struct{}{} 121 | } 122 | } 123 | return hosts 124 | } 125 | 126 | // reconcileDNSRecords reconciles the DNSRecords 127 | func (r *IngressReconciler) reconcileDNSRecords(ctx context.Context, ingress *networkingv1.Ingress, dnsRecordSpec cloudflareoperatoriov1.DNSRecordSpec, existingRecords map[string]cloudflareoperatoriov1.DNSRecord, ingressHosts map[string]struct{}) error { 128 | log := ctrl.LoggerFrom(ctx) 129 | 130 | for host := range ingressHosts { 131 | record, exists := existingRecords[host] 132 | dnsRecordSpec.Name = host 133 | 134 | if !exists { 135 | if err := r.createDNSRecord(ctx, ingress, dnsRecordSpec); err != nil { 136 | return fmt.Errorf("failed to create DNSRecord for %s: %w", host, err) 137 | } 138 | continue 139 | } 140 | 141 | if !reflect.DeepEqual(record.Spec, dnsRecordSpec) { 142 | record.Spec = dnsRecordSpec 143 | if err := r.Update(ctx, &record); err != nil { 144 | return fmt.Errorf("failed to update DNSRecord for %s: %w", host, err) 145 | } 146 | } 147 | } 148 | 149 | for host, record := range existingRecords { 150 | if _, exists := ingressHosts[host]; !exists { 151 | if err := r.Delete(ctx, &record); err != nil { 152 | log.Error(err, "Failed to delete DNSRecord", "host", host) 153 | } 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // parseAnnotations parses ingress annotations and returns a DNSRecordSpec 161 | func (r *IngressReconciler) parseAnnotations(annotations map[string]string) cloudflareoperatoriov1.DNSRecordSpec { 162 | dnsRecordSpec := cloudflareoperatoriov1.DNSRecordSpec{} 163 | 164 | dnsRecordSpec.Content = annotations["cloudflare-operator.io/content"] 165 | dnsRecordSpec.IPRef.Name = annotations["cloudflare-operator.io/ip-ref"] 166 | 167 | proxied, err := strconv.ParseBool(annotations["cloudflare-operator.io/proxied"]) 168 | if err != nil { 169 | proxied = true 170 | } 171 | 172 | dnsRecordSpec.Proxied = &proxied 173 | ttl, err := strconv.Atoi(annotations["cloudflare-operator.io/ttl"]) 174 | if err != nil { 175 | ttl = 1 176 | } 177 | if *dnsRecordSpec.Proxied && ttl != 1 { 178 | ttl = 1 179 | } 180 | dnsRecordSpec.TTL = ttl 181 | 182 | dnsRecordSpec.Type = annotations["cloudflare-operator.io/type"] 183 | if dnsRecordSpec.Type == "" { 184 | dnsRecordSpec.Type = "A" 185 | } 186 | 187 | intervalDuration, err := time.ParseDuration(annotations["cloudflare-operator.io/interval"]) 188 | if err != nil { 189 | intervalDuration = r.DefaultReconcileInterval 190 | } 191 | dnsRecordSpec.Interval = metav1.Duration{Duration: intervalDuration} 192 | 193 | return dnsRecordSpec 194 | } 195 | 196 | // createDNSRecord creates a DNSRecord object 197 | func (r *IngressReconciler) createDNSRecord(ctx context.Context, ingress *networkingv1.Ingress, dnsRecordSpec cloudflareoperatoriov1.DNSRecordSpec) error { 198 | replacer := strings.NewReplacer(".", "-", "*", "wildcard") 199 | dnsRecord := &cloudflareoperatoriov1.DNSRecord{ 200 | ObjectMeta: metav1.ObjectMeta{ 201 | Name: replacer.Replace(dnsRecordSpec.Name), 202 | Namespace: ingress.Namespace, 203 | Labels: map[string]string{ 204 | "app.kubernetes.io/managed-by": "cloudflare-operator", 205 | }, 206 | }, 207 | Spec: dnsRecordSpec, 208 | } 209 | if err := controllerutil.SetControllerReference(ingress, dnsRecord, r.Scheme); err != nil { 210 | return err 211 | } 212 | return r.Create(ctx, dnsRecord) 213 | } 214 | -------------------------------------------------------------------------------- /internal/controller/ingress_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "testing" 22 | "time" 23 | 24 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 25 | . "github.com/onsi/gomega" 26 | 27 | networkingv1 "k8s.io/api/networking/v1" 28 | apierrors "k8s.io/apimachinery/pkg/api/errors" 29 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 32 | ) 33 | 34 | func TestIngressReconciler_reconcileIngress(t *testing.T) { 35 | ingress := &networkingv1.Ingress{ 36 | ObjectMeta: metav1.ObjectMeta{ 37 | Name: "ingress", 38 | Namespace: "default", 39 | Annotations: map[string]string{ 40 | "cloudflare-operator.io/content": "1.1.1.1", 41 | }, 42 | }, 43 | } 44 | 45 | r := &IngressReconciler{ 46 | Client: fake.NewClientBuilder(). 47 | WithScheme(NewTestScheme()). 48 | WithObjects(ingress). 49 | WithIndex(&cloudflareoperatoriov1.DNSRecord{}, cloudflareoperatoriov1.OwnerRefUIDIndexKey, func(obj client.Object) []string { 50 | dnsRecord, ok := obj.(*cloudflareoperatoriov1.DNSRecord) 51 | if !ok { 52 | return nil 53 | } 54 | if len(dnsRecord.OwnerReferences) == 0 { 55 | return nil 56 | } 57 | return []string{string(dnsRecord.OwnerReferences[0].UID)} 58 | }). 59 | Build(), 60 | Scheme: NewTestScheme(), 61 | } 62 | 63 | t.Run("reconcile ingress", func(t *testing.T) { 64 | g := NewWithT(t) 65 | ingress.Spec = networkingv1.IngressSpec{ 66 | Rules: []networkingv1.IngressRule{{ 67 | Host: "ingtest.containeroo-test.org", 68 | }}, 69 | } 70 | _, err := r.reconcileIngress(context.TODO(), ingress) 71 | g.Expect(err).NotTo(HaveOccurred()) 72 | 73 | dnsRecord := &cloudflareoperatoriov1.DNSRecord{} 74 | err = r.Get(context.TODO(), client.ObjectKey{Namespace: "default", Name: "ingtest-containeroo-test-org"}, dnsRecord) 75 | g.Expect(err).NotTo(HaveOccurred()) 76 | 77 | g.Expect(dnsRecord.Spec).To(HaveField("Name", Equal("ingtest.containeroo-test.org"))) 78 | g.Expect(dnsRecord.Spec).To(HaveField("Content", Equal("1.1.1.1"))) 79 | }) 80 | 81 | t.Run("change dnsrecord spec when annotations change", func(t *testing.T) { 82 | g := NewWithT(t) 83 | ingress.Annotations = map[string]string{ 84 | "cloudflare-operator.io/content": "2.2.2.2", 85 | } 86 | 87 | _, err := r.reconcileIngress(context.TODO(), ingress) 88 | g.Expect(err).NotTo(HaveOccurred()) 89 | 90 | dnsRecord := &cloudflareoperatoriov1.DNSRecord{} 91 | err = r.Get(context.TODO(), client.ObjectKey{Namespace: "default", Name: "ingtest-containeroo-test-org"}, dnsRecord) 92 | g.Expect(err).NotTo(HaveOccurred()) 93 | 94 | g.Expect(dnsRecord.Spec).To(HaveField("Name", Equal("ingtest.containeroo-test.org"))) 95 | g.Expect(dnsRecord.Spec).To(HaveField("Content", Equal("2.2.2.2"))) 96 | }) 97 | 98 | t.Run("reconcile ingress wildcard", func(t *testing.T) { 99 | g := NewWithT(t) 100 | ingress.Spec = networkingv1.IngressSpec{ 101 | Rules: []networkingv1.IngressRule{{ 102 | Host: "*.containeroo-test.org", 103 | }}, 104 | } 105 | _, err := r.reconcileIngress(context.TODO(), ingress) 106 | g.Expect(err).NotTo(HaveOccurred()) 107 | 108 | dnsRecord := &cloudflareoperatoriov1.DNSRecord{} 109 | err = r.Get(context.TODO(), client.ObjectKey{Namespace: "default", Name: "wildcard-containeroo-test-org"}, dnsRecord) 110 | g.Expect(err).NotTo(HaveOccurred()) 111 | 112 | g.Expect(dnsRecord.Spec).To(HaveField("Name", Equal("*.containeroo-test.org"))) 113 | g.Expect(dnsRecord.Spec).To(HaveField("Content", Equal("2.2.2.2"))) 114 | }) 115 | 116 | t.Run("remove dnsrecord when annotations are absent", func(t *testing.T) { 117 | g := NewWithT(t) 118 | ingress.Annotations = map[string]string{} 119 | 120 | _, err := r.reconcileIngress(context.TODO(), ingress) 121 | g.Expect(err).NotTo(HaveOccurred()) 122 | 123 | dnsRecord := &cloudflareoperatoriov1.DNSRecord{} 124 | err = r.Get(context.TODO(), client.ObjectKey{Namespace: "default", Name: "wildcard-containeroo-test-org"}, dnsRecord) 125 | g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) 126 | }) 127 | 128 | t.Run("ingress annotation parsing", func(t *testing.T) { 129 | g := NewWithT(t) 130 | ingress.Annotations = map[string]string{ 131 | "cloudflare-operator.io/content": "1.1.1.1", 132 | "cloudflare-operator.io/ip-ref": "ip", 133 | "cloudflare-operator.io/proxied": "true", 134 | "cloudflare-operator.io/ttl": "120", // Expecting to return 1 because proxied is true 135 | "cloudflare-operator.io/type": "A", 136 | "cloudflare-operator.io/interval": "10s", 137 | } 138 | 139 | parsedSpec := r.parseAnnotations(ingress.Annotations) 140 | 141 | g.Expect(parsedSpec).To(HaveField("Content", Equal("1.1.1.1"))) 142 | g.Expect(parsedSpec.IPRef).To(HaveField("Name", Equal("ip"))) 143 | g.Expect(parsedSpec).To(HaveField("Proxied", Equal(&[]bool{true}[0]))) 144 | g.Expect(parsedSpec).To(HaveField("TTL", Equal(1))) 145 | g.Expect(parsedSpec).To(HaveField("Type", Equal("A"))) 146 | g.Expect(parsedSpec.Interval.Duration).To(Equal(10 * time.Second)) 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /internal/controller/ip_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "crypto/tls" 23 | "encoding/json" 24 | "errors" 25 | "fmt" 26 | "io" 27 | "math/rand/v2" 28 | "net" 29 | "net/http" 30 | "net/url" 31 | "regexp" 32 | "strings" 33 | "time" 34 | 35 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 36 | intconditions "github.com/containeroo/cloudflare-operator/internal/conditions" 37 | "github.com/containeroo/cloudflare-operator/internal/metrics" 38 | "github.com/fluxcd/pkg/runtime/patch" 39 | "github.com/itchyny/gojq" 40 | corev1 "k8s.io/api/core/v1" 41 | apierrors "k8s.io/apimachinery/pkg/api/errors" 42 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 43 | "k8s.io/apimachinery/pkg/runtime" 44 | apierrutil "k8s.io/apimachinery/pkg/util/errors" 45 | ctrl "sigs.k8s.io/controller-runtime" 46 | "sigs.k8s.io/controller-runtime/pkg/builder" 47 | "sigs.k8s.io/controller-runtime/pkg/client" 48 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 49 | "sigs.k8s.io/controller-runtime/pkg/predicate" 50 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 51 | ) 52 | 53 | // IPReconciler reconciles a IP object 54 | type IPReconciler struct { 55 | client.Client 56 | Scheme *runtime.Scheme 57 | 58 | HTTPClientTimeout time.Duration 59 | DefaultReconcileInterval time.Duration 60 | 61 | RetryInterval time.Duration 62 | } 63 | 64 | // SetupWithManager sets up the controller with the Manager. 65 | func (r *IPReconciler) SetupWithManager(mgr ctrl.Manager) error { 66 | return ctrl.NewControllerManagedBy(mgr). 67 | For(&cloudflareoperatoriov1.IP{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 68 | Complete(r) 69 | } 70 | 71 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=ips,verbs=get;list;watch;create;update;patch;delete 72 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=ips/status,verbs=get;update;patch 73 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=ips/finalizers,verbs=update 74 | // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch 75 | 76 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 77 | // move the current state of the cluster closer to the desired state. 78 | func (r *IPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { 79 | ip := &cloudflareoperatoriov1.IP{} 80 | if err := r.Get(ctx, req.NamespacedName, ip); err != nil { 81 | return ctrl.Result{}, client.IgnoreNotFound(err) 82 | } 83 | 84 | patchHelper := patch.NewSerialPatcher(ip, r.Client) 85 | 86 | defer func() { 87 | patchOpts := []patch.Option{} 88 | 89 | if errors.Is(retErr, reconcile.TerminalError(nil)) || (retErr == nil && (result.IsZero() || !result.Requeue)) { 90 | patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) 91 | } 92 | 93 | if err := patchHelper.Patch(ctx, ip, patchOpts...); err != nil { 94 | if !ip.DeletionTimestamp.IsZero() { 95 | err = apierrutil.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) 96 | } 97 | retErr = apierrutil.Reduce(apierrutil.NewAggregate([]error{retErr, err})) 98 | } 99 | }() 100 | 101 | if !ip.DeletionTimestamp.IsZero() { 102 | r.reconcileDelete(ip) 103 | return ctrl.Result{}, nil 104 | } 105 | 106 | if !controllerutil.ContainsFinalizer(ip, cloudflareoperatoriov1.CloudflareOperatorFinalizer) { 107 | controllerutil.AddFinalizer(ip, cloudflareoperatoriov1.CloudflareOperatorFinalizer) 108 | return ctrl.Result{Requeue: true}, nil 109 | } 110 | 111 | return r.reconcileIP(ctx, ip), nil 112 | } 113 | 114 | // reconcileIP reconciles the ip 115 | func (r *IPReconciler) reconcileIP(ctx context.Context, ip *cloudflareoperatoriov1.IP) ctrl.Result { 116 | switch ip.Spec.Type { 117 | case "static": 118 | if err := r.handleStatic(ip); err != nil { 119 | intconditions.MarkFalse(ip, err) 120 | return ctrl.Result{} 121 | } 122 | case "dynamic": 123 | if err := r.handleDynamic(ctx, ip); err != nil { 124 | intconditions.MarkFalse(ip, err) 125 | return ctrl.Result{RequeueAfter: r.RetryInterval} 126 | } 127 | } 128 | 129 | intconditions.MarkTrue(ip, "IP is ready") 130 | 131 | if ip.Spec.Type == "dynamic" { 132 | return ctrl.Result{RequeueAfter: ip.Spec.Interval.Duration} 133 | } 134 | 135 | return ctrl.Result{} 136 | } 137 | 138 | // handleStatic handles the static ip 139 | func (r *IPReconciler) handleStatic(ip *cloudflareoperatoriov1.IP) error { 140 | if ip.Spec.Address == "" { 141 | return errors.New("address is required for static IPs") 142 | } 143 | if net.ParseIP(ip.Spec.Address) == nil { 144 | return fmt.Errorf("IP address %q is not valid", ip.Spec.Address) 145 | } 146 | return nil 147 | } 148 | 149 | // handleDynamic handles the dynamic ip 150 | func (r *IPReconciler) handleDynamic(ctx context.Context, ip *cloudflareoperatoriov1.IP) error { 151 | if ip.Spec.Interval == nil { 152 | ip.Spec.Interval = &metav1.Duration{Duration: r.DefaultReconcileInterval} 153 | } 154 | if len(ip.Spec.IPSources) == 0 { 155 | return errors.New("IP sources are required for dynamic IPs") 156 | } 157 | // DeepCopy the ip sources to avoid modifying the original slice which would cause the object to be updated on every reconcile 158 | // which would lead to an infinite loop 159 | ipSources := ip.Spec.DeepCopy().IPSources 160 | rand.Shuffle(len(ipSources), func(i, j int) { 161 | ipSources[i], ipSources[j] = ipSources[j], ipSources[i] 162 | }) 163 | var ipSourceError error 164 | for _, source := range ipSources { 165 | response, err := r.getIPSource(ctx, source) 166 | if err != nil { 167 | ipSourceError = err 168 | continue 169 | } 170 | ip.Spec.Address = response 171 | ipSourceError = nil 172 | break 173 | } 174 | if ipSourceError != nil { 175 | return ipSourceError 176 | } 177 | return nil 178 | } 179 | 180 | // getIPSource returns the IP gathered from the IPSource 181 | func (r *IPReconciler) getIPSource(ctx context.Context, source cloudflareoperatoriov1.IPSpecIPSources) (string, error) { 182 | log := ctrl.LoggerFrom(ctx) 183 | 184 | if _, err := url.Parse(source.URL); err != nil { 185 | return "", fmt.Errorf("failed to parse URL %s: %s", source.URL, err) 186 | } 187 | 188 | tr := http.Transport{ 189 | TLSClientConfig: &tls.Config{InsecureSkipVerify: source.InsecureSkipVerify}, 190 | Proxy: http.ProxyFromEnvironment, 191 | } 192 | httpClient := &http.Client{Transport: &tr} 193 | req, err := http.NewRequest(source.RequestMethod, source.URL, io.Reader(bytes.NewBuffer([]byte(source.RequestBody)))) 194 | if err != nil { 195 | return "", fmt.Errorf("failed to create request: %s", err) 196 | } 197 | 198 | if source.RequestHeaders != nil { 199 | var requestHeaders map[string]string 200 | if err := json.Unmarshal(source.RequestHeaders.Raw, &requestHeaders); err != nil { 201 | return "", fmt.Errorf("failed to unmarshal request headers: %s", err) 202 | } 203 | 204 | for key, value := range requestHeaders { 205 | req.Header.Add(key, value) 206 | } 207 | } 208 | 209 | if source.RequestHeadersSecretRef.Name != "" { 210 | secret := &corev1.Secret{} 211 | if err := r.Get(ctx, client.ObjectKey{ 212 | Name: source.RequestHeadersSecretRef.Name, 213 | Namespace: source.RequestHeadersSecretRef.Namespace, 214 | }, secret); err != nil { 215 | return "", fmt.Errorf("failed to get secret %s: %s", source.RequestHeadersSecretRef.Name, err) 216 | } 217 | for key, value := range secret.Data { 218 | req.Header.Add(key, string(value)) 219 | } 220 | } 221 | 222 | httpClient.Timeout = r.HTTPClientTimeout 223 | req.Header.Add("User-Agent", "cloudflare-operator") 224 | 225 | resp, err := httpClient.Do(req) 226 | if err != nil { 227 | return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, err) 228 | } 229 | defer func(Body io.ReadCloser) { 230 | if err := Body.Close(); err != nil { 231 | log.Error(err, "Failed to close response body") 232 | } 233 | }(resp.Body) 234 | 235 | if resp.StatusCode != 200 { 236 | return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, resp.Status) 237 | } 238 | response, err := io.ReadAll(resp.Body) 239 | if err != nil { 240 | return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, err) 241 | } 242 | 243 | extractedIP := string(response) 244 | if source.ResponseJQFilter != "" { 245 | var jsonResponse any 246 | if err := json.Unmarshal(response, &jsonResponse); err != nil { 247 | return "", fmt.Errorf("failed to get IP from %s: %s", source.URL, err) 248 | } 249 | jq, err := gojq.Parse(source.ResponseJQFilter) 250 | if err != nil { 251 | return "", fmt.Errorf("failed to parse jq filter %s: %s", source.ResponseJQFilter, err) 252 | } 253 | iter := jq.Run(jsonResponse) 254 | result, ok := iter.Next() 255 | if !ok { 256 | return "", fmt.Errorf("failed to extract IP from %s. jq returned no results", source.URL) 257 | } 258 | extractedIP = fmt.Sprintf("%v", result) 259 | } 260 | 261 | if source.PostProcessingRegex != "" { 262 | re, err := regexp.Compile(source.PostProcessingRegex) 263 | if err != nil { 264 | return "", fmt.Errorf("failed to compile regex %s: %s", source.PostProcessingRegex, err) 265 | } 266 | match := re.FindStringSubmatch(extractedIP) 267 | if match == nil { 268 | return "", fmt.Errorf("failed to extract IP from %s. regex returned no matches", source.URL) 269 | } 270 | if len(match) < 2 { 271 | return "", fmt.Errorf("failed to extract IP from %s. regex returned no matches", source.URL) 272 | } 273 | extractedIP = match[1] 274 | } 275 | 276 | extractedIP = strings.TrimSpace(extractedIP) 277 | if net.ParseIP(extractedIP) == nil { 278 | return "", fmt.Errorf("ip from source %s is invalid: %s", source.URL, extractedIP) 279 | } 280 | 281 | return extractedIP, nil 282 | } 283 | 284 | // reconcileDelete reconciles the deletion of the ip 285 | func (r *IPReconciler) reconcileDelete(ip *cloudflareoperatoriov1.IP) { 286 | metrics.IpFailureCounter.DeleteLabelValues(ip.Name, ip.Spec.Type) 287 | controllerutil.RemoveFinalizer(ip, cloudflareoperatoriov1.CloudflareOperatorFinalizer) 288 | } 289 | -------------------------------------------------------------------------------- /internal/controller/ip_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "net/http" 22 | "testing" 23 | 24 | "github.com/fluxcd/pkg/runtime/conditions" 25 | . "github.com/onsi/gomega" 26 | 27 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 28 | 29 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 30 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 31 | 32 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 33 | corev1 "k8s.io/api/core/v1" 34 | ) 35 | 36 | var ( 37 | requestHeader string 38 | requestAuthHeader string 39 | ) 40 | 41 | func StartIPSource() { 42 | http.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) { 43 | _, _ = w.Write([]byte("1.1.1.1")) 44 | }) 45 | http.HandleFunc("/invalid", func(w http.ResponseWriter, r *http.Request) { 46 | _, _ = w.Write([]byte("invalid")) 47 | }) 48 | http.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) { 49 | _, _ = w.Write([]byte(`{"ip":"1.1.1.1"}`)) 50 | }) 51 | http.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) { 52 | requestHeader = r.Header.Get("X-Test") 53 | requestAuthHeader = r.Header.Get("X-Auth-Test") 54 | _, _ = w.Write([]byte("1.1.1.1")) 55 | }) 56 | 57 | _ = http.ListenAndServe(":8080", nil) 58 | } 59 | 60 | func TestIPReconciler_reconcileIP(t *testing.T) { 61 | g := NewWithT(t) 62 | 63 | secret := &corev1.Secret{ 64 | ObjectMeta: metav1.ObjectMeta{ 65 | Name: "secret", 66 | Namespace: "default", 67 | }, 68 | Data: map[string][]byte{ 69 | "X-Auth-Test": []byte("auth-test"), 70 | }, 71 | } 72 | 73 | ip := &cloudflareoperatoriov1.IP{ 74 | ObjectMeta: metav1.ObjectMeta{ 75 | Name: "ip", 76 | }, 77 | Spec: cloudflareoperatoriov1.IPSpec{ 78 | Type: "dynamic", 79 | }, 80 | } 81 | 82 | r := &IPReconciler{ 83 | Client: fake.NewClientBuilder(). 84 | WithScheme(NewTestScheme()). 85 | WithObjects(secret). 86 | Build(), 87 | } 88 | 89 | go StartIPSource() 90 | 91 | t.Run("reconcile dynamic ip plain text", func(t *testing.T) { 92 | ip.Spec.IPSources = []cloudflareoperatoriov1.IPSpecIPSources{{ 93 | URL: "http://localhost:8080/plain", 94 | }} 95 | 96 | _ = r.reconcileIP(context.TODO(), ip) 97 | 98 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 99 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "IP is ready"), 100 | })) 101 | 102 | g.Expect(ip.Spec.Address).To(Equal("1.1.1.1")) 103 | }) 104 | 105 | t.Run("reconcile dynamic ip plain text error invalid ip", func(t *testing.T) { 106 | ip.Spec.IPSources = []cloudflareoperatoriov1.IPSpecIPSources{{ 107 | URL: "http://localhost:8080/invalid", 108 | }} 109 | 110 | _ = r.reconcileIP(context.TODO(), ip) 111 | 112 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 113 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "ip from source http://localhost:8080/invalid is invalid: invalid"), 114 | })) 115 | }) 116 | 117 | t.Run("reconcile dynamic ip jq filter", func(t *testing.T) { 118 | ip.Spec.IPSources = []cloudflareoperatoriov1.IPSpecIPSources{{ 119 | URL: "http://localhost:8080/json", 120 | ResponseJQFilter: ".ip", 121 | }} 122 | 123 | _ = r.reconcileIP(context.TODO(), ip) 124 | 125 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 126 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "IP is ready"), 127 | })) 128 | 129 | g.Expect(ip.Spec.Address).To(Equal("1.1.1.1")) 130 | }) 131 | 132 | t.Run("reconcile dynamic ip regex", func(t *testing.T) { 133 | ip.Spec.IPSources = []cloudflareoperatoriov1.IPSpecIPSources{{ 134 | URL: "http://localhost:8080/json", 135 | PostProcessingRegex: "([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)", 136 | }} 137 | 138 | _ = r.reconcileIP(context.TODO(), ip) 139 | 140 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 141 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "IP is ready"), 142 | })) 143 | 144 | g.Expect(ip.Spec.Address).To(Equal("1.1.1.1")) 145 | }) 146 | 147 | t.Run("reconcile dynamic ip with header", func(t *testing.T) { 148 | ip.Spec.IPSources = []cloudflareoperatoriov1.IPSpecIPSources{{ 149 | URL: "http://localhost:8080/header", 150 | RequestHeaders: &apiextensionsv1.JSON{ 151 | Raw: []byte(`{"X-Test":"test"}`), 152 | }, 153 | }} 154 | 155 | _ = r.reconcileIP(context.TODO(), ip) 156 | 157 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 158 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "IP is ready"), 159 | })) 160 | 161 | g.Expect(ip.Spec.Address).To(Equal("1.1.1.1")) 162 | g.Expect(requestHeader).To(Equal("test")) 163 | }) 164 | 165 | t.Run("reconcile dynamic ip with header from secret", func(t *testing.T) { 166 | ip.Spec.IPSources = []cloudflareoperatoriov1.IPSpecIPSources{{ 167 | URL: "http://localhost:8080/header", 168 | RequestHeadersSecretRef: corev1.SecretReference{ 169 | Name: "secret", 170 | Namespace: "default", 171 | }, 172 | }} 173 | 174 | _ = r.reconcileIP(context.TODO(), ip) 175 | 176 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 177 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "IP is ready"), 178 | })) 179 | 180 | g.Expect(ip.Spec.Address).To(Equal("1.1.1.1")) 181 | g.Expect(requestAuthHeader).To(Equal("auth-test")) 182 | }) 183 | 184 | t.Run("reconcile static ip", func(t *testing.T) { 185 | ip.Spec.Type = "static" 186 | ip.Spec.Address = "1.1.1.1" 187 | 188 | _ = r.reconcileIP(context.TODO(), ip) 189 | 190 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 191 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionTypeReady, "IP is ready"), 192 | })) 193 | g.Expect(ip.Spec.Address).To(Equal("1.1.1.1")) 194 | }) 195 | 196 | t.Run("reconcile static ip error no address", func(t *testing.T) { 197 | ip.Spec.Address = "" 198 | 199 | _ = r.reconcileIP(context.TODO(), ip) 200 | 201 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 202 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "address is required for static IPs"), 203 | })) 204 | }) 205 | 206 | t.Run("reconcile static ip error invalid address", func(t *testing.T) { 207 | ip.Spec.Address = "invalid" 208 | 209 | _ = r.reconcileIP(context.TODO(), ip) 210 | 211 | g.Expect(ip.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 212 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "IP address \"invalid\" is not valid"), 213 | })) 214 | }) 215 | } 216 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "fmt" 21 | "path/filepath" 22 | "runtime" 23 | "testing" 24 | 25 | . "github.com/onsi/ginkgo/v2" 26 | . "github.com/onsi/gomega" 27 | 28 | "k8s.io/client-go/kubernetes/scheme" 29 | "k8s.io/client-go/rest" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/envtest" 32 | logf "sigs.k8s.io/controller-runtime/pkg/log" 33 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 34 | 35 | networkingv1 "k8s.io/api/networking/v1" 36 | 37 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 38 | // +kubebuilder:scaffold:imports 39 | ) 40 | 41 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 42 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 43 | 44 | var ( 45 | cfg *rest.Config 46 | k8sClient client.Client 47 | testEnv *envtest.Environment 48 | ) 49 | 50 | func TestControllers(t *testing.T) { 51 | RegisterFailHandler(Fail) 52 | 53 | RunSpecs(t, "Controller Suite") 54 | } 55 | 56 | var _ = BeforeSuite(func() { 57 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 58 | 59 | By("bootstrapping test environment") 60 | testEnv = &envtest.Environment{ 61 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 62 | ErrorIfCRDPathMissing: true, 63 | 64 | // The BinaryAssetsDirectory is only required if you want to run the tests directly 65 | // without call the makefile target test. If not informed it will look for the 66 | // default path defined in controller-runtime which is /usr/local/kubebuilder/. 67 | // Note that you must have the required binaries setup under the bin directory to perform 68 | // the tests directly. When we run make test it will be setup and used automatically. 69 | BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", 70 | fmt.Sprintf("1.28.3-%s-%s", runtime.GOOS, runtime.GOARCH)), 71 | } 72 | 73 | var err error 74 | // cfg is defined in this file globally. 75 | cfg, err = testEnv.Start() 76 | Expect(err).NotTo(HaveOccurred()) 77 | Expect(cfg).NotTo(BeNil()) 78 | 79 | err = cloudflareoperatoriov1.AddToScheme(scheme.Scheme) 80 | Expect(err).NotTo(HaveOccurred()) 81 | 82 | err = networkingv1.AddToScheme(scheme.Scheme) 83 | Expect(err).NotTo(HaveOccurred()) 84 | 85 | // +kubebuilder:scaffold:scheme 86 | 87 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 88 | Expect(err).NotTo(HaveOccurred()) 89 | Expect(k8sClient).NotTo(BeNil()) 90 | }) 91 | 92 | var _ = AfterSuite(func() { 93 | By("tearing down the test environment") 94 | err := testEnv.Stop() 95 | Expect(err).NotTo(HaveOccurred()) 96 | }) 97 | -------------------------------------------------------------------------------- /internal/controller/zone_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "strings" 24 | "time" 25 | 26 | "github.com/cloudflare/cloudflare-go" 27 | apierrors "k8s.io/apimachinery/pkg/api/errors" 28 | apierrutil "k8s.io/apimachinery/pkg/util/errors" 29 | "sigs.k8s.io/controller-runtime/pkg/builder" 30 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 31 | "sigs.k8s.io/controller-runtime/pkg/predicate" 32 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 33 | 34 | "k8s.io/apimachinery/pkg/runtime" 35 | ctrl "sigs.k8s.io/controller-runtime" 36 | "sigs.k8s.io/controller-runtime/pkg/client" 37 | 38 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 39 | intconditions "github.com/containeroo/cloudflare-operator/internal/conditions" 40 | interrors "github.com/containeroo/cloudflare-operator/internal/errors" 41 | "github.com/containeroo/cloudflare-operator/internal/metrics" 42 | "github.com/fluxcd/pkg/runtime/patch" 43 | ) 44 | 45 | // ignoredRecords are records that should not be pruned 46 | // the key is the record type and the value is a list of prefixes 47 | // TODO: make this configurable in the Zone CR 48 | var ignoredRecords = map[string][]string{ 49 | "TXT": { 50 | "_acme-challenge", // Let's Encrypt DNS-01 challenge 51 | "cf2024-1._domainkey", // Cloudflare Email Routing DKIM 52 | }, 53 | } 54 | 55 | // ZoneReconciler reconciles a Zone object 56 | type ZoneReconciler struct { 57 | client.Client 58 | Scheme *runtime.Scheme 59 | 60 | RetryInterval time.Duration 61 | 62 | CloudflareAPI *cloudflare.API 63 | } 64 | 65 | var errWaitForZone = errors.New("must wait for zone") 66 | 67 | // SetupWithManager sets up the controller with the Manager. 68 | func (r *ZoneReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { 69 | if err := mgr.GetFieldIndexer().IndexField(ctx, &cloudflareoperatoriov1.Zone{}, cloudflareoperatoriov1.ZoneNameIndexKey, 70 | func(rawObj client.Object) []string { 71 | zone := rawObj.(*cloudflareoperatoriov1.Zone) 72 | return []string{zone.Spec.Name} 73 | }); err != nil { 74 | return err 75 | } 76 | 77 | return ctrl.NewControllerManagedBy(mgr). 78 | For(&cloudflareoperatoriov1.Zone{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 79 | Complete(r) 80 | } 81 | 82 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=zones,verbs=get;list;watch;create;update;patch;delete 83 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=zones/status,verbs=get;update;patch 84 | // +kubebuilder:rbac:groups=cloudflare-operator.io,resources=zones/finalizers,verbs=update 85 | 86 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 87 | // move the current state of the cluster closer to the desired state. 88 | func (r *ZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { 89 | zone := &cloudflareoperatoriov1.Zone{} 90 | if err := r.Get(ctx, req.NamespacedName, zone); err != nil { 91 | return ctrl.Result{}, client.IgnoreNotFound(err) 92 | } 93 | 94 | patchHelper := patch.NewSerialPatcher(zone, r.Client) 95 | 96 | defer func() { 97 | patchOpts := []patch.Option{} 98 | 99 | if errors.Is(retErr, reconcile.TerminalError(nil)) || (retErr == nil && (result.IsZero() || !result.Requeue)) { 100 | patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) 101 | } 102 | 103 | // We do not want to return these errors, but rather wait for the 104 | // designated RequeueAfter to expire and try again. 105 | // However, not returning an error will cause the patch helper to 106 | // patch the observed generation, which we do not want. So we ignore 107 | // these errors here after patching. 108 | retErr = interrors.Ignore(retErr, errWaitForAccount, errWaitForZone) 109 | 110 | if err := patchHelper.Patch(ctx, zone, patchOpts...); err != nil { 111 | if !zone.DeletionTimestamp.IsZero() { 112 | err = apierrutil.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) 113 | } 114 | retErr = apierrutil.Reduce(apierrutil.NewAggregate([]error{retErr, err})) 115 | } 116 | }() 117 | 118 | if !zone.DeletionTimestamp.IsZero() { 119 | r.reconcileDelete(zone) 120 | return ctrl.Result{}, nil 121 | } 122 | 123 | if !controllerutil.ContainsFinalizer(zone, cloudflareoperatoriov1.CloudflareOperatorFinalizer) { 124 | controllerutil.AddFinalizer(zone, cloudflareoperatoriov1.CloudflareOperatorFinalizer) 125 | return ctrl.Result{Requeue: true}, nil 126 | } 127 | 128 | return r.reconcileZone(ctx, zone) 129 | } 130 | 131 | // reconcileZone reconciles the zone 132 | func (r *ZoneReconciler) reconcileZone(ctx context.Context, zone *cloudflareoperatoriov1.Zone) (ctrl.Result, error) { 133 | if r.CloudflareAPI.APIToken == "" { 134 | intconditions.MarkUnknown(zone, "Cloudflare account is not ready") 135 | return ctrl.Result{RequeueAfter: r.RetryInterval}, errWaitForAccount 136 | } 137 | 138 | zoneID, err := r.CloudflareAPI.ZoneIDByName(zone.Spec.Name) 139 | if err != nil { 140 | intconditions.MarkFalse(zone, err) 141 | return ctrl.Result{RequeueAfter: r.RetryInterval}, errWaitForZone 142 | } 143 | 144 | zone.Status.ID = zoneID 145 | 146 | if zone.Spec.Prune { 147 | if err := r.handlePrune(ctx, zone); err != nil { 148 | intconditions.MarkFalse(zone, fmt.Errorf("failed to prune DNS records: %v", err)) 149 | return ctrl.Result{RequeueAfter: r.RetryInterval}, nil 150 | } 151 | } 152 | 153 | intconditions.MarkTrue(zone, "Zone is ready") 154 | 155 | return ctrl.Result{RequeueAfter: zone.Spec.Interval.Duration}, nil 156 | } 157 | 158 | // handlePrune deletes DNS records that are not managed by the operator if enabled 159 | func (r *ZoneReconciler) handlePrune(ctx context.Context, zone *cloudflareoperatoriov1.Zone) error { 160 | log := ctrl.LoggerFrom(ctx) 161 | 162 | dnsRecords := &cloudflareoperatoriov1.DNSRecordList{} 163 | if err := r.List(ctx, dnsRecords); err != nil { 164 | log.Error(err, "Failed to list DNSRecords") 165 | return client.IgnoreNotFound(err) 166 | } 167 | 168 | cloudflareDNSRecords, _, err := r.CloudflareAPI.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zone.Status.ID), cloudflare.ListDNSRecordsParams{}) 169 | if err != nil { 170 | intconditions.MarkFalse(zone, err) 171 | return err 172 | } 173 | 174 | dnsRecordMap := make(map[string]struct{}) 175 | for _, dnsRecord := range dnsRecords.Items { 176 | dnsRecordMap[dnsRecord.Status.RecordID] = struct{}{} 177 | } 178 | 179 | for _, cloudflareDNSRecord := range cloudflareDNSRecords { 180 | if _, found := ignoredRecords[cloudflareDNSRecord.Type]; found && hasPrefix(cloudflareDNSRecord.Name, ignoredRecords[cloudflareDNSRecord.Type]) { 181 | continue 182 | } 183 | 184 | if _, found := dnsRecordMap[cloudflareDNSRecord.ID]; !found { 185 | if err := r.CloudflareAPI.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zone.Status.ID), cloudflareDNSRecord.ID); err != nil && err.Error() != "Record does not exist. (81044)" { 186 | return err 187 | } 188 | log.Info("Deleted DNS record on Cloudflare", "name", cloudflareDNSRecord.Name) 189 | } 190 | } 191 | return nil 192 | } 193 | 194 | // reconcileDelete reconciles the deletion of the zone 195 | func (r *ZoneReconciler) reconcileDelete(zone *cloudflareoperatoriov1.Zone) { 196 | metrics.ZoneFailureCounter.DeleteLabelValues(zone.Name, zone.Spec.Name) 197 | controllerutil.RemoveFinalizer(zone, cloudflareoperatoriov1.CloudflareOperatorFinalizer) 198 | } 199 | 200 | // hasPrefix checks if the name has any of the prefixes 201 | func hasPrefix(name string, prefixes []string) bool { 202 | for _, prefix := range prefixes { 203 | if strings.HasPrefix(name, prefix) { 204 | return true 205 | } 206 | } 207 | return false 208 | } 209 | -------------------------------------------------------------------------------- /internal/controller/zone_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "testing" 23 | 24 | "github.com/cloudflare/cloudflare-go" 25 | "github.com/fluxcd/pkg/runtime/conditions" 26 | . "github.com/onsi/gomega" 27 | 28 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 29 | 30 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 31 | 32 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 33 | ) 34 | 35 | func TestZoneReconciler_reconcileZone(t *testing.T) { 36 | zone := &cloudflareoperatoriov1.Zone{ 37 | ObjectMeta: metav1.ObjectMeta{ 38 | Name: "zone", 39 | }, 40 | Spec: cloudflareoperatoriov1.ZoneSpec{ 41 | Name: "containeroo-test.org", 42 | }, 43 | } 44 | 45 | r := &ZoneReconciler{ 46 | Client: fake.NewClientBuilder(). 47 | WithScheme(NewTestScheme()). 48 | WithObjects(zone). 49 | Build(), 50 | CloudflareAPI: &cloudflareAPI, 51 | } 52 | 53 | zoneID := os.Getenv("CF_ZONE_ID") 54 | var testRecord cloudflare.DNSRecord 55 | 56 | t.Run("create dns record for testing", func(t *testing.T) { 57 | g := NewWithT(t) 58 | 59 | var err error 60 | testRecord, err = cloudflareAPI.CreateDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zoneID), cloudflare.CreateDNSRecordParams{ 61 | Name: "test.containeroo-test.org", 62 | Content: "1.1.1.1", 63 | Type: "A", 64 | }) 65 | g.Expect(err).ToNot(HaveOccurred()) 66 | }) 67 | 68 | t.Run("reconcile zone without prune", func(t *testing.T) { 69 | g := NewWithT(t) 70 | 71 | zone.Spec.Prune = false 72 | 73 | _, err := r.reconcileZone(context.TODO(), zone) 74 | g.Expect(err).ToNot(HaveOccurred()) 75 | 76 | g.Expect(zone.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 77 | *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "Zone is ready"), 78 | })) 79 | g.Expect(zone.Status.ID).To(Equal(zoneID)) 80 | 81 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zoneID), testRecord.ID) 82 | g.Expect(err).ToNot(HaveOccurred()) 83 | }) 84 | 85 | t.Run("reconcile zone with prune", func(t *testing.T) { 86 | g := NewWithT(t) 87 | 88 | zone.Spec.Prune = true 89 | 90 | acmeRecord, err := cloudflareAPI.CreateDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zoneID), cloudflare.CreateDNSRecordParams{ 91 | Name: "_acme-challenge.abc.containeroo-test.org", 92 | Type: "TXT", 93 | Content: "test", 94 | }) 95 | g.Expect(err).ToNot(HaveOccurred()) 96 | dkimRecord, err := cloudflareAPI.CreateDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zoneID), cloudflare.CreateDNSRecordParams{ 97 | Name: "cf2024-1._domainkey.containeroo-test.org", 98 | Type: "TXT", 99 | Content: "test", 100 | }) 101 | g.Expect(err).ToNot(HaveOccurred()) 102 | 103 | _, _ = r.reconcileZone(context.TODO(), zone) 104 | 105 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), testRecord.ID) 106 | g.Expect(err.Error()).To(ContainSubstring("Record does not exist")) 107 | 108 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), acmeRecord.ID) 109 | g.Expect(err).ToNot(HaveOccurred()) 110 | _, err = cloudflareAPI.GetDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zone.Status.ID), dkimRecord.ID) 111 | g.Expect(err).ToNot(HaveOccurred()) 112 | 113 | err = cloudflareAPI.DeleteDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zoneID), acmeRecord.ID) 114 | g.Expect(err).ToNot(HaveOccurred()) 115 | err = cloudflareAPI.DeleteDNSRecord(context.TODO(), cloudflare.ZoneIdentifier(zoneID), dkimRecord.ID) 116 | g.Expect(err).ToNot(HaveOccurred()) 117 | }) 118 | 119 | t.Run("reconcile zone error zone not found", func(t *testing.T) { 120 | g := NewWithT(t) 121 | 122 | zone.Spec.Name = "not-found.org" 123 | 124 | _, err := r.reconcileZone(context.TODO(), zone) 125 | g.Expect(err).To(HaveOccurred()) 126 | 127 | g.Expect(zone.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 128 | *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "zone could not be found"), 129 | })) 130 | }) 131 | 132 | t.Run("reconcile zone error account not ready", func(t *testing.T) { 133 | g := NewWithT(t) 134 | 135 | cloudflareAPI.APIToken = "" 136 | 137 | _, err := r.reconcileZone(context.TODO(), zone) 138 | g.Expect(err).To(Equal(errWaitForAccount)) 139 | 140 | g.Expect(zone.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ 141 | *conditions.UnknownCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonNotReady, "Cloudflare account is not ready"), 142 | })) 143 | }) 144 | } 145 | -------------------------------------------------------------------------------- /internal/errors/ignore.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | This file is copied from the source at 17 | https://github.com/fluxcd/helm-controller/blob/25c6bb691df30a3c2fd8a4b19ce65bc134158c90/internal/errors/ignore.go 18 | */ 19 | 20 | package errors 21 | 22 | // Ignore returns nil if err is equal to any of the errs. 23 | func Ignore(err error, errs ...error) error { 24 | if IsOneOf(err, errs...) { 25 | return nil 26 | } 27 | return err 28 | } 29 | -------------------------------------------------------------------------------- /internal/errors/is.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | This file is copied from the source at 17 | https://github.com/fluxcd/helm-controller/blob/25c6bb691df30a3c2fd8a4b19ce65bc134158c90/internal/errors/is.go 18 | */ 19 | 20 | package errors 21 | 22 | import "errors" 23 | 24 | // IsOneOf returns true if err is equal to any of the errs. 25 | func IsOneOf(err error, errs ...error) bool { 26 | for _, e := range errs { 27 | if errors.Is(err, e) { 28 | return true 29 | } 30 | } 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package metrics 18 | 19 | import ( 20 | "github.com/prometheus/client_golang/prometheus" 21 | k8smetrics "sigs.k8s.io/controller-runtime/pkg/metrics" 22 | ) 23 | 24 | var ( 25 | AccountFailureCounter = prometheus.NewGaugeVec( 26 | prometheus.GaugeOpts{ 27 | Name: "cloudflare_operator_account_status", 28 | Help: "Cloudflare account status", 29 | }, 30 | []string{"name"}, 31 | ) 32 | DnsRecordFailureCounter = prometheus.NewGaugeVec( 33 | prometheus.GaugeOpts{ 34 | Name: "cloudflare_operator_dns_record_status", 35 | Help: "Cloudflare DNS records status", 36 | }, 37 | []string{"namespace", "name", "record_name"}, 38 | ) 39 | IpFailureCounter = prometheus.NewGaugeVec( 40 | prometheus.GaugeOpts{ 41 | Name: "cloudflare_operator_ip_status", 42 | Help: "IPs status", 43 | }, 44 | []string{"name", "ip_type"}, 45 | ) 46 | ZoneFailureCounter = prometheus.NewGaugeVec( 47 | prometheus.GaugeOpts{ 48 | Name: "cloudflare_operator_zone_status", 49 | Help: "Cloudflare zones status", 50 | }, 51 | []string{"name", "zone_name"}, 52 | ) 53 | ) 54 | 55 | func init() { 56 | k8smetrics.Registry.MustRegister(AccountFailureCounter, DnsRecordFailureCounter, IpFailureCounter, ZoneFailureCounter) 57 | } 58 | -------------------------------------------------------------------------------- /internal/predicates/predicates.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package predicates 18 | 19 | import ( 20 | "reflect" 21 | "slices" 22 | 23 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 24 | networkingv1 "k8s.io/api/networking/v1" 25 | "sigs.k8s.io/controller-runtime/pkg/event" 26 | "sigs.k8s.io/controller-runtime/pkg/predicate" 27 | ) 28 | 29 | // DNSFromIngressPredicate detects if an Ingress object has the required annotations, 30 | // has changed annoations or has changed hosts. 31 | type DNSFromIngressPredicate struct { 32 | predicate.Funcs 33 | } 34 | 35 | func ingressHasRequiredAnnotations(annotations map[string]string) bool { 36 | if annotations == nil { 37 | return false 38 | } 39 | return annotations["cloudflare-operator.io/content"] != "" || annotations["cloudflare-operator.io/ip-ref"] != "" 40 | } 41 | 42 | func (DNSFromIngressPredicate) Create(e event.CreateEvent) bool { 43 | return ingressHasRequiredAnnotations(e.Object.GetAnnotations()) 44 | } 45 | 46 | func (DNSFromIngressPredicate) Update(e event.UpdateEvent) bool { 47 | oldAnnotations := e.ObjectOld.GetAnnotations() 48 | newAnnotations := e.ObjectNew.GetAnnotations() 49 | 50 | oldObjectHadRequiredAnnotations := ingressHasRequiredAnnotations(oldAnnotations) 51 | newObjectHasRequiredAnnotations := ingressHasRequiredAnnotations(newAnnotations) 52 | 53 | oldObj := e.ObjectOld.(*networkingv1.Ingress) 54 | newObj := e.ObjectNew.(*networkingv1.Ingress) 55 | 56 | annotationsChanged := !reflect.DeepEqual(oldAnnotations, newAnnotations) 57 | oldHosts := []string{} 58 | for _, rule := range oldObj.Spec.Rules { 59 | oldHosts = append(oldHosts, rule.Host) 60 | } 61 | newHosts := []string{} 62 | for _, rule := range newObj.Spec.Rules { 63 | newHosts = append(newHosts, rule.Host) 64 | } 65 | 66 | slices.Sort(oldHosts) 67 | slices.Sort(newHosts) 68 | 69 | hostsChanged := !reflect.DeepEqual(oldHosts, newHosts) 70 | 71 | return (newObjectHasRequiredAnnotations && (annotationsChanged || hostsChanged)) || (oldObjectHadRequiredAnnotations && !newObjectHasRequiredAnnotations) 72 | } 73 | 74 | func (DNSFromIngressPredicate) Delete(e event.DeleteEvent) bool { 75 | return false 76 | } 77 | 78 | // IPAddressChangedPredicate detects if an Ingress object has a change in the IP address. 79 | type IPAddressChangedPredicate struct { 80 | predicate.Funcs 81 | } 82 | 83 | func (IPAddressChangedPredicate) Update(e event.UpdateEvent) bool { 84 | oldObj := e.ObjectOld.(*cloudflareoperatoriov1.IP) 85 | newObj := e.ObjectNew.(*cloudflareoperatoriov1.IP) 86 | 87 | return oldObj.Spec.Address != newObj.Spec.Address 88 | } 89 | -------------------------------------------------------------------------------- /internal/predicates/predicates_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package predicates 18 | 19 | import ( 20 | "testing" 21 | 22 | . "github.com/onsi/gomega" 23 | "sigs.k8s.io/controller-runtime/pkg/event" 24 | 25 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 26 | 27 | cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" 28 | networkingv1 "k8s.io/api/networking/v1" 29 | ) 30 | 31 | func TestPredicate(t *testing.T) { 32 | predicate := DNSFromIngressPredicate{} 33 | 34 | t.Run("create ingress annotation predicate with no annotations", func(t *testing.T) { 35 | g := NewWithT(t) 36 | 37 | ingress := &networkingv1.Ingress{ 38 | ObjectMeta: metav1.ObjectMeta{ 39 | Name: "ingress", 40 | }, 41 | } 42 | 43 | result := predicate.Create(event.CreateEvent{Object: ingress}) 44 | 45 | g.Expect(result).To(BeFalse()) 46 | }) 47 | 48 | t.Run("create ingress annotation predicate with annotation", func(t *testing.T) { 49 | g := NewWithT(t) 50 | 51 | ingress := &networkingv1.Ingress{ 52 | ObjectMeta: metav1.ObjectMeta{ 53 | Name: "ingress", 54 | Annotations: map[string]string{ 55 | "cloudflare-operator.io/content": "test", 56 | }, 57 | }, 58 | } 59 | 60 | predicate := DNSFromIngressPredicate{} 61 | result := predicate.Create(event.CreateEvent{Object: ingress}) 62 | 63 | g.Expect(result).To(BeTrue()) 64 | }) 65 | 66 | t.Run("update ingress annotation predicate with content annotation", func(t *testing.T) { 67 | g := NewWithT(t) 68 | 69 | oldIngress := &networkingv1.Ingress{ 70 | ObjectMeta: metav1.ObjectMeta{ 71 | Name: "ingress", 72 | Annotations: map[string]string{ 73 | "cloudflare-operator.io/content": "test", 74 | }, 75 | }, 76 | Spec: networkingv1.IngressSpec{ 77 | Rules: []networkingv1.IngressRule{{ 78 | Host: "test.containeroo-test.org", 79 | }}, 80 | }, 81 | } 82 | 83 | newIngress := &networkingv1.Ingress{ 84 | ObjectMeta: metav1.ObjectMeta{ 85 | Name: "ingress", 86 | Annotations: map[string]string{ 87 | "cloudflare-operator.io/content": "new-test", 88 | }, 89 | }, 90 | Spec: networkingv1.IngressSpec{ 91 | Rules: []networkingv1.IngressRule{{ 92 | Host: "test.containeroo-test.org", 93 | }}, 94 | }, 95 | } 96 | 97 | predicate := DNSFromIngressPredicate{} 98 | result := predicate.Update(event.UpdateEvent{ObjectOld: oldIngress, ObjectNew: newIngress}) 99 | 100 | g.Expect(result).To(BeTrue()) 101 | }) 102 | 103 | t.Run("update ingress rules predicate", func(t *testing.T) { 104 | g := NewWithT(t) 105 | 106 | oldIngress := &networkingv1.Ingress{ 107 | ObjectMeta: metav1.ObjectMeta{ 108 | Name: "ingress", 109 | Annotations: map[string]string{ 110 | "cloudflare-operator.io/content": "test", 111 | }, 112 | }, 113 | Spec: networkingv1.IngressSpec{ 114 | Rules: []networkingv1.IngressRule{{ 115 | Host: "test.containeroo-test.org", 116 | }}, 117 | }, 118 | } 119 | 120 | newIngress := &networkingv1.Ingress{ 121 | ObjectMeta: metav1.ObjectMeta{ 122 | Name: "ingress", 123 | Annotations: map[string]string{ 124 | "cloudflare-operator.io/content": "test", 125 | }, 126 | }, 127 | Spec: networkingv1.IngressSpec{ 128 | Rules: []networkingv1.IngressRule{{ 129 | Host: "test-new.containeroo-test.org", 130 | }}, 131 | }, 132 | } 133 | 134 | predicate := DNSFromIngressPredicate{} 135 | result := predicate.Update(event.UpdateEvent{ObjectOld: oldIngress, ObjectNew: newIngress}) 136 | 137 | g.Expect(result).To(BeTrue()) 138 | }) 139 | 140 | t.Run("update ip dnsrecord predicate", func(t *testing.T) { 141 | g := NewWithT(t) 142 | 143 | oldIP := &cloudflareoperatoriov1.IP{ 144 | Spec: cloudflareoperatoriov1.IPSpec{ 145 | Address: "1.1.1.1", 146 | }, 147 | } 148 | 149 | newIP := &cloudflareoperatoriov1.IP{ 150 | Spec: cloudflareoperatoriov1.IPSpec{ 151 | Address: "2.2.2.2", 152 | }, 153 | } 154 | 155 | predicate := IPAddressChangedPredicate{} 156 | result := predicate.Update(event.UpdateEvent{ObjectOld: oldIP, ObjectNew: newIP}) 157 | 158 | g.Expect(result).To(BeTrue()) 159 | }) 160 | } 161 | -------------------------------------------------------------------------------- /test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package e2e 18 | 19 | import ( 20 | "fmt" 21 | "testing" 22 | 23 | . "github.com/onsi/ginkgo/v2" 24 | . "github.com/onsi/gomega" 25 | ) 26 | 27 | // Run e2e tests using the Ginkgo runner. 28 | func TestE2E(t *testing.T) { 29 | RegisterFailHandler(Fail) 30 | fmt.Fprintf(GinkgoWriter, "Starting cloudflare-operator suite\n") // nolint:errcheck 31 | RunSpecs(t, "e2e suite") 32 | } 33 | -------------------------------------------------------------------------------- /test/e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package e2e 18 | 19 | import ( 20 | "fmt" 21 | "os/exec" 22 | "time" 23 | 24 | . "github.com/onsi/ginkgo/v2" 25 | . "github.com/onsi/gomega" 26 | 27 | "github.com/containeroo/cloudflare-operator/test/utils" 28 | ) 29 | 30 | const namespace = "cloudflare-operator-system" 31 | 32 | var _ = Describe("controller", Ordered, func() { 33 | BeforeAll(func() { 34 | By("installing prometheus operator") 35 | Expect(utils.InstallPrometheusOperator()).To(Succeed()) 36 | 37 | By("creating manager namespace") 38 | cmd := exec.Command("kubectl", "create", "ns", namespace) 39 | _, _ = utils.Run(cmd) 40 | }) 41 | 42 | AfterAll(func() { 43 | By("uninstalling the Prometheus manager bundle") 44 | utils.UninstallPrometheusOperator() 45 | 46 | By("removing all ingresses") 47 | cmd := exec.Command("kubectl", "delete", "ingresses", "--all", "--all-namespaces") 48 | _, _ = utils.Run(cmd) 49 | 50 | By("removing all dnsrecords") 51 | cmd = exec.Command("kubectl", "delete", "dnsrecords", "--all", "--all-namespaces") 52 | _, _ = utils.Run(cmd) 53 | 54 | By("removing all ips") 55 | cmd = exec.Command("kubectl", "delete", "ips", "--all", "--all-namespaces") 56 | _, _ = utils.Run(cmd) 57 | 58 | By("removing all zones") 59 | cmd = exec.Command("kubectl", "delete", "zones", "--all", "--all-namespaces") 60 | _, _ = utils.Run(cmd) 61 | 62 | By("removing all accounts") 63 | cmd = exec.Command("kubectl", "delete", "accounts", "--all", "--all-namespaces") 64 | _, _ = utils.Run(cmd) 65 | 66 | By("removing manager namespace") 67 | cmd = exec.Command("kubectl", "delete", "ns", namespace) 68 | _, _ = utils.Run(cmd) 69 | }) 70 | 71 | Context("Operator", func() { 72 | It("should run successfully", func() { 73 | var controllerPodName string 74 | var err error 75 | 76 | // projectimage stores the name of the image used in the example 77 | projectimage := "containeroo/cloudflare-operator:test" 78 | 79 | By("building the manager(Operator) image") 80 | cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) 81 | _, err = utils.Run(cmd) 82 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 83 | 84 | By("loading the the manager(Operator) image on Kind") 85 | err = utils.LoadImageToKindClusterWithName(projectimage) 86 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 87 | 88 | By("installing CRDs") 89 | cmd = exec.Command("make", "install") 90 | _, err = utils.Run(cmd) 91 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 92 | 93 | By("deploying the controller-manager") 94 | cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage)) 95 | _, err = utils.Run(cmd) 96 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 97 | 98 | By("validating that the controller-manager pod is running as expected") 99 | verifyControllerUp := func() error { 100 | // Get pod name 101 | 102 | cmd = exec.Command("kubectl", "get", 103 | "pods", "-l", "control-plane=controller-manager", 104 | "-o", "go-template={{ range .items }}"+ 105 | "{{ if not .metadata.deletionTimestamp }}"+ 106 | "{{ .metadata.name }}"+ 107 | "{{ \"\\n\" }}{{ end }}{{ end }}", 108 | "-n", namespace, 109 | ) 110 | 111 | podOutput, err := utils.Run(cmd) 112 | ExpectWithOffset(2, err).NotTo(HaveOccurred()) 113 | podNames := utils.GetNonEmptyLines(string(podOutput)) 114 | if len(podNames) != 1 { 115 | return fmt.Errorf("expect 1 controller pods running, but got %d", len(podNames)) 116 | } 117 | controllerPodName = podNames[0] 118 | ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("controller-manager")) 119 | 120 | // Validate pod status 121 | cmd = exec.Command("kubectl", "get", 122 | "pods", controllerPodName, "-o", "jsonpath={.status.phase}", 123 | "-n", namespace, 124 | ) 125 | status, err := utils.Run(cmd) 126 | ExpectWithOffset(2, err).NotTo(HaveOccurred()) 127 | if string(status) != "Running" { 128 | return fmt.Errorf("controller pod in %s status", status) 129 | } 130 | return nil 131 | } 132 | EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) 133 | }) 134 | }) 135 | 136 | It("should reconcile account", func() { 137 | command := "envsubst < config/samples/cloudflareoperatorio_v1_account.yaml | kubectl apply -f -" 138 | cmd := exec.Command("sh", "-c", command) 139 | _, err := utils.Run(cmd) 140 | Expect(err).NotTo(HaveOccurred()) 141 | 142 | Eventually(utils.VerifyObjectReady, time.Minute, time.Second). 143 | WithArguments("account", "account-sample").Should(Succeed()) 144 | }) 145 | 146 | It("should reconcile zone", func() { 147 | cmd := exec.Command("kubectl", "apply", "-f", "config/samples/cloudflareoperatorio_v1_zone.yaml") 148 | _, err := utils.Run(cmd) 149 | Expect(err).NotTo(HaveOccurred()) 150 | 151 | Eventually(utils.VerifyObjectReady, time.Minute, time.Second).WithArguments("zone", "zone-sample").Should(Succeed()) 152 | }) 153 | 154 | It("should reconcile ip", func() { 155 | cmd := exec.Command("kubectl", "apply", "-f", "config/samples/cloudflareoperatorio_v1_ip.yaml") 156 | _, err := utils.Run(cmd) 157 | Expect(err).NotTo(HaveOccurred()) 158 | 159 | Eventually(utils.VerifyObjectReady, time.Minute, time.Second). 160 | WithArguments("ip", "ip-sample").Should(Succeed()) 161 | }) 162 | 163 | It("should reconcile dnsreocrd", func() { 164 | cmd := exec.Command("kubectl", "apply", "-f", "config/samples/cloudflareoperatorio_v1_dnsrecord.yaml") 165 | _, err := utils.Run(cmd) 166 | Expect(err).NotTo(HaveOccurred()) 167 | 168 | Eventually(utils.VerifyObjectReady, time.Minute, time.Second). 169 | WithArguments("dnsrecord", "dnsrecord-sample").Should(Succeed()) 170 | }) 171 | 172 | It("should reconcile dnsrecord with ipRef", func() { 173 | Eventually(utils.VerifyDNSRecordContent, time.Minute, time.Second). 174 | WithArguments("dnsrecord-ip-ref-sample", "1.1.1.1").Should(Succeed()) 175 | }) 176 | 177 | It("should reconcile dnsrecord with ipRef when ip changes", func() { 178 | cmd := exec.Command("kubectl", "patch", "ip", "ip-sample", "--type=merge", "-p", `{"spec":{"address":"9.9.9.9"}}`) 179 | _, err := utils.Run(cmd) 180 | Expect(err).NotTo(HaveOccurred()) 181 | 182 | Eventually(utils.VerifyDNSRecordContent, time.Minute, time.Second). 183 | WithArguments("dnsrecord-ip-ref-sample", "9.9.9.9").Should(Succeed()) 184 | }) 185 | 186 | It("should create dnsrecord from an ingress", func() { 187 | cmd := exec.Command("kubectl", "apply", "-f", "config/samples/networking_v1_ingress.yaml") 188 | _, err := utils.Run(cmd) 189 | Expect(err).NotTo(HaveOccurred()) 190 | 191 | Eventually(utils.VerifyObjectReady, time.Minute, time.Second). 192 | WithArguments("dnsrecord", "ingress-containeroo-test-org").Should(Succeed()) 193 | }) 194 | 195 | It("should update dnsrecord when ingress annotations change", func() { 196 | cmd := exec.Command( 197 | "kubectl", "-n", namespace, "patch", "ingress", "ingress-sample", 198 | "--type=merge", "-p", `{"metadata":{"annotations":{"cloudflare-operator.io/content":"145.145.145.145"}}}`) 199 | _, err := utils.Run(cmd) 200 | Expect(err).NotTo(HaveOccurred()) 201 | 202 | Eventually(utils.VerifyDNSRecordContent, time.Minute, time.Second). 203 | WithArguments("ingress-containeroo-test-org", "145.145.145.145").Should(Succeed()) 204 | }) 205 | 206 | It("should recreate dnsrecord when it gets deleted", func() { 207 | cmd := exec.Command("kubectl", "-n", namespace, "delete", "dnsrecord", "ingress-containeroo-test-org") 208 | _, err := utils.Run(cmd) 209 | Expect(err).NotTo(HaveOccurred()) 210 | 211 | Eventually(utils.VerifyObjectReady, time.Minute, time.Second). 212 | WithArguments("dnsrecord", "ingress-containeroo-test-org").Should(Succeed()) 213 | }) 214 | 215 | It("should delete dnsrecord when ingress annotations are absent", func() { 216 | cmd := exec.Command( 217 | "kubectl", "-n", namespace, "patch", "ingress", "ingress-sample", 218 | "--type=json", "-p", `[{"op": "remove", "path": "/metadata/annotations"}]`) 219 | _, err := utils.Run(cmd) 220 | Expect(err).NotTo(HaveOccurred()) 221 | 222 | Eventually(utils.VerifyDNSRecordAbsent, time.Minute, time.Second). 223 | WithArguments("ingress-containeroo-test-org").Should(Succeed()) 224 | }) 225 | }) 226 | -------------------------------------------------------------------------------- /test/utils/utils.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 containeroo 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "os/exec" 23 | "strings" 24 | 25 | . "github.com/onsi/ginkgo/v2" //nolint:revive,staticcheck 26 | ) 27 | 28 | const ( 29 | prometheusOperatorVersion = "v0.68.0" 30 | prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + 31 | "releases/download/%s/bundle.yaml" 32 | ) 33 | 34 | func warnError(err error) { 35 | fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) // nolint:errcheck 36 | } 37 | 38 | // InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. 39 | func InstallPrometheusOperator() error { 40 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 41 | cmd := exec.Command("kubectl", "create", "-f", url) 42 | _, err := Run(cmd) 43 | return err 44 | } 45 | 46 | // Run executes the provided command within this context 47 | func Run(cmd *exec.Cmd) ([]byte, error) { 48 | dir, _ := GetProjectDir() 49 | cmd.Dir = dir 50 | 51 | if err := os.Chdir(cmd.Dir); err != nil { 52 | fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) // nolint:errcheck 53 | } 54 | 55 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 56 | command := strings.Join(cmd.Args, " ") 57 | fmt.Fprintf(GinkgoWriter, "running: %s\n", command) // nolint:errcheck 58 | output, err := cmd.CombinedOutput() 59 | if err != nil { 60 | return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) 61 | } 62 | 63 | return output, nil 64 | } 65 | 66 | // UninstallPrometheusOperator uninstalls the prometheus 67 | func UninstallPrometheusOperator() { 68 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 69 | cmd := exec.Command("kubectl", "delete", "-f", url) 70 | if _, err := Run(cmd); err != nil { 71 | warnError(err) 72 | } 73 | } 74 | 75 | // LoadImageToKindCluster loads a local docker image to the kind cluster 76 | func LoadImageToKindClusterWithName(name string) error { 77 | cluster := "cloudflare-operator-test" 78 | if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { 79 | cluster = v 80 | } 81 | kindOptions := []string{"load", "docker-image", name, "--name", cluster} 82 | cmd := exec.Command("kind", kindOptions...) 83 | _, err := Run(cmd) 84 | return err 85 | } 86 | 87 | // GetNonEmptyLines converts given command output string into individual objects 88 | // according to line breakers, and ignores the empty elements in it. 89 | func GetNonEmptyLines(output string) []string { 90 | var res []string 91 | for element := range strings.SplitSeq(output, "\n") { 92 | if element != "" { 93 | res = append(res, element) 94 | } 95 | } 96 | 97 | return res 98 | } 99 | 100 | // GetProjectDir will return the directory where the project is 101 | func GetProjectDir() (string, error) { 102 | wd, err := os.Getwd() 103 | if err != nil { 104 | return wd, err 105 | } 106 | wd = strings.ReplaceAll(wd, "/test/e2e", "") 107 | return wd, nil 108 | } 109 | 110 | func VerifyObjectReady(objType, objName string) error { 111 | cmd := exec.Command( 112 | "kubectl", 113 | "get", 114 | objType, 115 | objName, 116 | "-n", 117 | "cloudflare-operator-system", 118 | "-o", 119 | "jsonpath={.status.conditions[?(@.type=='Ready')].status}", 120 | ) 121 | status, err := Run(cmd) 122 | if err != nil { 123 | return err 124 | } 125 | if string(status) != "True" { 126 | return fmt.Errorf("%s %s is not ready", objType, objName) 127 | } 128 | return nil 129 | } 130 | 131 | func VerifyDNSRecordContent(objName, expectedContent string) error { 132 | cmd := exec.Command( 133 | "kubectl", 134 | "get", 135 | "dnsrecord", 136 | objName, 137 | "-n", 138 | "cloudflare-operator-system", 139 | "-o", 140 | "jsonpath={.spec.content}", 141 | ) 142 | ip, err := Run(cmd) 143 | if err != nil { 144 | return err 145 | } 146 | if string(ip) != expectedContent { 147 | return fmt.Errorf("dnsrecord has unexpected content: %s", ip) 148 | } 149 | return nil 150 | } 151 | 152 | func VerifyDNSRecordAbsent(objName string) error { 153 | cmd := exec.Command( 154 | "kubectl", 155 | "get", 156 | "dnsrecord", 157 | objName, 158 | "-n", 159 | "cloudflare-operator-system", 160 | ) 161 | status, err := Run(cmd) 162 | if err != nil { 163 | if strings.Contains(string(status), "NotFound") { 164 | return nil 165 | } 166 | return err 167 | } 168 | return fmt.Errorf("dnsrecord %s still exists", objName) 169 | } 170 | --------------------------------------------------------------------------------