├── charts ├── values.yaml ├── templates │ ├── rbac │ │ ├── controller │ │ │ ├── service_account.yaml │ │ │ ├── metrics.yaml │ │ │ ├── leader_election.yaml │ │ │ ├── role.yaml │ │ │ └── bindings.yaml │ │ ├── providers │ │ │ ├── events.yaml │ │ │ ├── leader_election.yaml │ │ │ ├── service_account.yaml │ │ │ ├── crds.yaml │ │ │ └── bindings.yaml │ │ └── solver │ │ │ └── bindings.yaml │ ├── solver │ │ ├── role.yaml │ │ ├── service.yaml │ │ ├── role-binding.yaml │ │ ├── apiService.yaml │ │ └── certs.yaml │ ├── _helpers.tpl │ └── controller │ │ └── deployment.yaml ├── .helmignore └── Chart.yaml ├── docs ├── status.png ├── integrations │ ├── aws │ │ ├── cluster-page.png │ │ └── index.md │ ├── cloudflare │ │ ├── token-page.png │ │ ├── profile-page.png │ │ └── index.md │ ├── gcore │ │ └── index.md │ ├── desec │ │ └── index.md │ ├── create │ │ └── index.md │ ├── azure │ │ └── index.md │ └── _index.md ├── get_started.md ├── _index.md ├── dns-01 │ └── _index.md └── errors.md ├── .dockerignore ├── test ├── samples │ ├── dnsrecord.yaml │ ├── issuer.yaml │ └── cert.yaml ├── e2e │ ├── e2e_suite_test.go │ └── e2e_test.go └── utils │ └── utils.go ├── pkg ├── providers │ ├── azure │ │ └── utils.go │ ├── provider.go │ ├── gcore │ │ ├── client.go │ │ └── client_test.go │ ├── aws │ │ ├── client_test.go │ │ └── client.go │ ├── desec │ │ ├── client.go │ │ └── client_test.go │ └── cloudflare │ │ ├── client.go │ │ └── client_test.go ├── mocks │ └── updater.go ├── utils │ └── env.go └── server │ └── server.go ├── api └── v1alpha1 │ ├── integrations │ └── const.go │ ├── references │ ├── secret.go │ ├── reference.go │ └── zz_generated.deepcopy.go │ ├── groupversion_info.go │ ├── dnsintegration_types.go │ └── dnsrecord_types.go ├── .github └── workflows │ ├── test.yaml │ ├── docs.yml │ └── release.yaml ├── .gitignore ├── cmd └── providers │ ├── desec │ └── main.go │ ├── gcore │ └── main.go │ ├── aws │ └── main.go │ ├── azure │ └── main.go │ └── cloudflare │ └── main.go ├── hack └── boilerplate.go.txt ├── PROJECT ├── .golangci.yml ├── Dockerfile.controller ├── README.md ├── internal ├── reconcilers │ ├── controller │ │ ├── tasks │ │ │ └── integrations │ │ │ │ ├── health.go │ │ │ │ └── deployment.go │ │ ├── dnsintegration_controller_test.go │ │ ├── suite_test.go │ │ ├── dnsintegration_controller.go │ │ ├── dnsrecord_controller.go │ │ └── dnsrecord_controller_test.go │ └── provider │ │ └── provider.go └── solver │ ├── server.go │ ├── solver_test.go │ └── solver.go ├── Dockerfile.providers └── go.mod /charts/values.yaml: -------------------------------------------------------------------------------- 1 | solver: 2 | enabled: false 3 | -------------------------------------------------------------------------------- /docs/status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pier-oliviert/phonebook/HEAD/docs/status.png -------------------------------------------------------------------------------- /docs/integrations/aws/cluster-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pier-oliviert/phonebook/HEAD/docs/integrations/aws/cluster-page.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /docs/integrations/cloudflare/token-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pier-oliviert/phonebook/HEAD/docs/integrations/cloudflare/token-page.png -------------------------------------------------------------------------------- /docs/integrations/cloudflare/profile-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pier-oliviert/phonebook/HEAD/docs/integrations/cloudflare/profile-page.png -------------------------------------------------------------------------------- /charts/templates/rbac/controller/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | {{- include "operator.labels" . | nindent 4 }} 7 | name: phonebook-controller 8 | namespace: {{ .Release.Namespace }} 9 | -------------------------------------------------------------------------------- /test/samples/dnsrecord.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: se.quencer.io.my.domain/v1alpha1 2 | kind: DNSRecord 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | app.kubernetes.io/managed-by: kustomize 7 | name: dnsrecord-sample 8 | spec: 9 | # TODO(user): Add fields here 10 | -------------------------------------------------------------------------------- /pkg/providers/azure/utils.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 7 | ) 8 | 9 | // Helper function to convert string to *int32 10 | func toInt32Ptr(s string) *int32 { 11 | i, err := strconv.Atoi(s) 12 | if err != nil { 13 | return nil 14 | } 15 | return to.Ptr(int32(i)) 16 | } 17 | -------------------------------------------------------------------------------- /charts/templates/rbac/providers/events.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | name: phonebook:providers-events 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - events 12 | verbs: 13 | - create 14 | - patch 15 | -------------------------------------------------------------------------------- /api/v1alpha1/integrations/const.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import "github.com/pier-oliviert/konditionner/pkg/konditions" 4 | 5 | const ( 6 | DeploymentCondition konditions.ConditionType = "Deployment" 7 | HealthCondition konditions.ConditionType = "Health" 8 | 9 | DeploymentFinalizer = "phonebook.se.quencer.io/deployment" 10 | DeploymentLabel = "phonebook.se.quencer.io/deployment" 11 | ) 12 | -------------------------------------------------------------------------------- /charts/templates/rbac/controller/metrics.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: phonebook:metrics 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /charts/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | 25 | -------------------------------------------------------------------------------- /charts/templates/rbac/providers/leader_election.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | name: phonebook:providers-leader-election 7 | rules: 8 | - apiGroups: 9 | - coordination.k8s.io 10 | resources: 11 | - leases 12 | verbs: 13 | - get 14 | - list 15 | - watch 16 | - create 17 | - update 18 | - patch 19 | - delete 20 | -------------------------------------------------------------------------------- /charts/templates/rbac/providers/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | {{- include "operator.labels" . | nindent 4 }} 7 | name: phonebook-providers 8 | namespace: {{ .Release.Namespace }} 9 | annotations: 10 | {{- if ((.Values.serviceAccount).annotations) }} 11 | {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} 12 | {{- end }} 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/samples/issuer.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: cert-manager.io/v1 3 | kind: ClusterIssuer 4 | metadata: 5 | name: phonebook-acme-issuer 6 | spec: 7 | acme: 8 | email: "youremail@exmaple.com" 9 | server: "https://acme-v02.api.letsencrypt.org/directory" 10 | privateKeySecretRef: 11 | name: acme-issuer 12 | solvers: 13 | - dns01: 14 | webhook: 15 | groupName: phonebook.se.quencer.io 16 | solverName: solver 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | pull_request: 6 | branches: [ "main" ] 7 | 8 | jobs: 9 | tests: 10 | name: Test Suite 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '1.22.x' 21 | 22 | - name: Test 23 | run: make test 24 | -------------------------------------------------------------------------------- /charts/templates/solver/role.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.solver.enabled }} 2 | 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | name: phonebook:dns01-solver 7 | labels: 8 | {{- include "operator.labels" . | nindent 4 }} 9 | rules: 10 | - apiGroups: 11 | - phonebook.se.quencer.io 12 | resources: 13 | - 'solver' 14 | verbs: 15 | - 'create' 16 | - apiGroups: ["se.quencer.io"] 17 | resources: ["dnsrecords"] 18 | verbs: ["*"] 19 | 20 | {{- end }} 21 | -------------------------------------------------------------------------------- /charts/templates/solver/service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.solver.enabled }} 2 | 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: solver 7 | namespace: {{ .Release.Namespace }} 8 | labels: 9 | se.quencer.io/solver: phonebook-solver 10 | {{- include "operator.labels" . | nindent 4 }} 11 | spec: 12 | type: ClusterIP 13 | ports: 14 | - port: 443 15 | targetPort: 4443 16 | protocol: TCP 17 | name: https 18 | selector: 19 | se.quencer.io/solver: phonebook-solver 20 | 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /charts/templates/rbac/providers/crds.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: phonebook:providers 5 | labels: {{- include "operator.labels" . | nindent 4 }} 6 | rules: 7 | - apiGroups: 8 | - se.quencer.io 9 | resources: 10 | - dnsrecords 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - se.quencer.io 17 | resources: 18 | - dnsrecords/status 19 | verbs: 20 | - get 21 | - patch 22 | - update 23 | -------------------------------------------------------------------------------- /api/v1alpha1/references/secret.go: -------------------------------------------------------------------------------- 1 | package references 2 | 3 | import ( 4 | core "k8s.io/api/core/v1" 5 | ) 6 | 7 | // +kubebuilder:object:generate=true 8 | type SecretRef struct { 9 | Keys []SecretKey `json:"keys"` 10 | Name string `json:"name"` 11 | } 12 | 13 | func (sr SecretRef) Selector() core.LocalObjectReference { 14 | return core.LocalObjectReference{ 15 | Name: sr.Name, 16 | } 17 | } 18 | 19 | // +kubebuilder:object:generate=true 20 | type SecretKey struct { 21 | Key string `json:"key"` 22 | Name string `json:"name"` 23 | } 24 | -------------------------------------------------------------------------------- /pkg/mocks/updater.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "github.com/pier-oliviert/konditionner/pkg/konditions" 5 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 6 | ) 7 | 8 | type Updater struct { 9 | Status *konditions.ConditionStatus 10 | Reason *string 11 | 12 | Info phonebook.IntegrationInfo 13 | } 14 | 15 | func (u *Updater) StageCondition(status konditions.ConditionStatus, reason string) { 16 | u.Status = &status 17 | u.Reason = &reason 18 | } 19 | 20 | func (u *Updater) StageRemoteInfo(info phonebook.IntegrationInfo) { 21 | u.Info = info 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/* 8 | Dockerfile.cross 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Go workspace file 17 | go.work 18 | 19 | # Kubernetes Generated files - skip generated files, except for vendored files 20 | !vendor/**/zz_generated.* 21 | 22 | # editor and IDE paraphernalia 23 | .idea 24 | .vscode 25 | *.swp 26 | *.swo 27 | *~ 28 | 29 | # OS X 30 | .DS_Store 31 | 32 | testValues/* -------------------------------------------------------------------------------- /charts/templates/rbac/solver/bindings.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.solver.enabled }} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: RoleBinding 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: phonebook 7 | {{- include "operator.labels" . | nindent 4 }} 8 | 9 | name: phonebook:solver 10 | namespace: kube-system 11 | 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: Role 15 | name: extension-apiserver-authentication-reader 16 | subjects: 17 | - kind: ServiceAccount 18 | name: phonebook-controller 19 | namespace: {{ .Release.Namespace }} 20 | {{- end }} 21 | 22 | -------------------------------------------------------------------------------- /cmd/providers/desec/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pier-oliviert/phonebook/pkg/providers/desec" 7 | "github.com/pier-oliviert/phonebook/pkg/server" 8 | "sigs.k8s.io/controller-runtime/pkg/log" 9 | ) 10 | 11 | func main() { 12 | var err error 13 | 14 | ctx := context.Background() 15 | logger := log.FromContext(ctx) 16 | 17 | logger.Info("Initializing deSEC Client") 18 | p, err := desec.NewClient(ctx) 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | srv := server.NewServer(p) 24 | if err := srv.Run(); err != nil { 25 | panic(err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cmd/providers/gcore/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pier-oliviert/phonebook/pkg/providers/gcore" 7 | "github.com/pier-oliviert/phonebook/pkg/server" 8 | "sigs.k8s.io/controller-runtime/pkg/log" 9 | ) 10 | 11 | func main() { 12 | var err error 13 | 14 | ctx := context.Background() 15 | logger := log.FromContext(ctx) 16 | 17 | logger.Info("Initializing gcore Client") 18 | p, err := gcore.NewClient(ctx) 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | srv := server.NewServer(p) 24 | if err := srv.Run(); err != nil { 25 | panic(err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | */ -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 8 | permissions: 9 | contents: write 10 | id-token: write 11 | 12 | jobs: 13 | # Copy Documentation to the gh-pages branch 14 | copy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - uses: peaceiris/actions-gh-pages@v4 21 | with: 22 | github_token: ${{ secrets.GH_PAGES_AUTO_COMMIT }} 23 | publish_dir: ./docs 24 | destination_dir: ./docs 25 | 26 | -------------------------------------------------------------------------------- /charts/templates/solver/role-binding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.solver.enabled }} 2 | 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRoleBinding 5 | metadata: 6 | name: phonebook:cert-manager-domain-solver 7 | labels: 8 | {{- include "operator.labels" . | nindent 4 }} 9 | roleRef: 10 | apiGroup: rbac.authorization.k8s.io 11 | kind: ClusterRole 12 | name: phonebook:dns01-solver 13 | 14 | {{- with .Values.solver.certManager }} 15 | subjects: 16 | - apiGroup: "" 17 | kind: ServiceAccount 18 | name: {{ .serviceAccount.name }} 19 | namespace: {{ .serviceAccount.namespace }} 20 | {{- end }} 21 | 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /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: se.quencer.io 6 | layout: 7 | - go.kubebuilder.io/v4 8 | projectName: phonebook 9 | repo: github.com/pier-oliviert/phonebook 10 | resources: 11 | - api: 12 | crdVersion: v1 13 | namespaced: true 14 | controller: true 15 | domain: se.quencer.io 16 | group: se.quencer.io 17 | kind: DNSRecord 18 | path: github.com/pier-oliviert/phonebook/api/v1alpha1 19 | version: v1alpha1 20 | version: "3" 21 | -------------------------------------------------------------------------------- /charts/templates/solver/apiService.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.solver.enabled }} 2 | 3 | apiVersion: apiregistration.k8s.io/v1 4 | kind: APIService 5 | metadata: 6 | name: v1alpha1.phonebook.se.quencer.io 7 | labels: 8 | se.quencer.io/solver: phonebook-solver 9 | {{- include "operator.labels" . | nindent 4 }} 10 | annotations: 11 | cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/phonebook-solver" 12 | spec: 13 | group: phonebook.se.quencer.io 14 | groupPriorityMinimum: 1000 15 | versionPriority: 15 16 | service: 17 | name: solver 18 | namespace: {{ .Release.Namespace }} 19 | version: v1alpha1 20 | 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /charts/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{- define "operator.chart" -}} 2 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 3 | {{- end }} 4 | 5 | {{- define "operator.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 7 | {{- end }} 8 | 9 | {{- define "operator.defaultImage" -}} 10 | {{- printf "ghcr.io/pier-oliviert/phonebook:v%s" .Chart.Version }} 11 | {{- end }} 12 | 13 | {{- define "operator.labels" -}} 14 | helm.sh/chart: {{ include "operator.chart" . }} 15 | {{- if .Chart.AppVersion }} 16 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 17 | {{- end }} 18 | app.kubernetes.io/managed-by: {{ .Release.Service }} 19 | app.kubernetes.io/part-of: {{ include "operator.name" . }} 20 | {{- end }} 21 | 22 | -------------------------------------------------------------------------------- /charts/templates/rbac/controller/leader_election.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: phonebook 7 | name: phonebook:leader-election 8 | rules: 9 | - apiGroups: 10 | - "" 11 | resources: 12 | - configmaps 13 | verbs: 14 | - get 15 | - list 16 | - watch 17 | - create 18 | - update 19 | - patch 20 | - delete 21 | - apiGroups: 22 | - coordination.k8s.io 23 | resources: 24 | - leases 25 | verbs: 26 | - get 27 | - list 28 | - watch 29 | - create 30 | - update 31 | - patch 32 | - delete 33 | - apiGroups: 34 | - "" 35 | resources: 36 | - events 37 | verbs: 38 | - create 39 | - patch 40 | -------------------------------------------------------------------------------- /charts/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: phonebook 3 | description: Manages DNS Record within Kubernetes 4 | # A chart can be either an 'application' or a 'library' chart. 5 | # 6 | # Application charts are a collection of templates that can be packaged into versioned archives 7 | # to be deployed. 8 | # 9 | # Library charts provide useful utilities or functions for the chart developer. They're included as 10 | # a dependency of application charts to inject those utilities and functions into the rendering 11 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 12 | type: application 13 | # AppVersion & version are defined by the github action by providing both the --version and the --app-version flag. 14 | # As such, to avoid confusion to anying reading this file, they are set to 0.0.0 15 | version: 0.0.0 16 | appVersion: 0.0.0 17 | -------------------------------------------------------------------------------- /pkg/utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "k8s.io/utils/env" 8 | ) 9 | 10 | const kPhonebookConfighPath = "/var/run/configs/provider" 11 | 12 | // First check if the environment variable is set, if not, let's look for the 13 | // token at `${kProviderConfigPath}/${kCloudflareAPIKeyName}` and read the content 14 | // of that file into token 15 | func RetrieveValueFromEnvOrFile(envNameOrFileName string) (content string, err error) { 16 | content = env.GetString(envNameOrFileName, "") 17 | 18 | if content == "" { 19 | path := fmt.Sprintf("%s/%s", kPhonebookConfighPath, envNameOrFileName) 20 | data, err := os.ReadFile(path) 21 | if err != nil { 22 | return "", fmt.Errorf("E#4002: %s does not exist as an environment variable and a file(%s) with this name could not be found", envNameOrFileName, path) 23 | } 24 | content = string(data) 25 | } 26 | 27 | return content, nil 28 | } 29 | -------------------------------------------------------------------------------- /docs/get_started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Get Started' 3 | date: 2024-09-20T10:38:15-04:00 4 | draft: false 5 | weight: 1 6 | --- 7 | 8 | The helm chart is the official way to install Phonebook in your cluster. 9 | 10 | 1. **Add helm repo** 11 | 12 | ```sh 13 | helm repo add phonebook https://pier-oliviert.github.io/phonebook/ --force-update 14 | ``` 15 | 16 | 2. **Install Phonebook** 17 | ```sh 18 | helm upgrade --install phonebook phonebook/phonebook \ 19 | --namespace phonebook-system \ 20 | --create-namespace 21 | ``` 22 | 23 | 3. **Create a DNSIntegration** 24 | 25 | Phonebook requires at least one DNSIntegration to work. These integrations can be seen as the glue between a DNS Provider (aws, cloudflare, azure, etc.) and a DNS Record created with Phonebook. Since each of the integration requires different settings and values, please refer to the [integrations]({{< ref "/integrations" >}}) section to learn how to create a DNSIntegration based off the provider you want to use. 26 | -------------------------------------------------------------------------------- /test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 phonebook suite\n") 31 | RunSpecs(t, "e2e suite") 32 | } 33 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | allow-parallel-runners: true 4 | 5 | issues: 6 | # don't skip warning about doc comments 7 | # don't exclude the default set of lint 8 | exclude-use-default: false 9 | # restore some of the defaults 10 | # (fill in the rest as needed) 11 | exclude-rules: 12 | - path: "api/*" 13 | linters: 14 | - lll 15 | - path: "internal/*" 16 | linters: 17 | - dupl 18 | - lll 19 | linters: 20 | disable-all: true 21 | enable: 22 | - dupl 23 | - errcheck 24 | - exportloopref 25 | - ginkgolinter 26 | - goconst 27 | - gocyclo 28 | - gofmt 29 | - goimports 30 | - gosimple 31 | - govet 32 | - ineffassign 33 | - lll 34 | - misspell 35 | - nakedret 36 | - prealloc 37 | - revive 38 | - staticcheck 39 | - typecheck 40 | - unconvert 41 | - unparam 42 | - unused 43 | 44 | linters-settings: 45 | revive: 46 | rules: 47 | - name: comment-spacings 48 | -------------------------------------------------------------------------------- /docs/integrations/gcore/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'GCore' 3 | date: 2024-10-22T10:38:15-04:00 4 | draft: false 5 | weight: 1 6 | --- 7 | 8 | GCore provided supports their [managed DNS](https://gcore.com/dns) offer. First, you'll need to [create an API token](https://portal.gcore.com/accounts/profile/api-tokens) and then store the value of the generated token into a secret. 9 | 10 | ```sh 11 | API_TOKEN="GCORE-generated-secret" 12 | kubectl create secrets generic gcore-secrets \ 13 | --namespace phonebook-system \ 14 | --from-literal=apiToken=${API_TOKEN} 15 | ``` 16 | 17 | ```yaml 18 | apiVersion: se.quencer.io/v1alpha1 19 | kind: DNSIntegration 20 | metadata: 21 | name: gcore 22 | spec: 23 | provider: 24 | name: gcore 25 | zones: 26 | - mydomain.com 27 | secretRef: 28 | name: gcore-secrets 29 | keys: 30 | - key: "apiToken" 31 | name: "GCORE_API_TOKEN" 32 | ``` 33 | 34 | ## Deploying 35 | 36 | Now you can deploy with the normal command: 37 | ``` 38 | helm upgrade --install phonebook phonebook/phonebook \ 39 | --namespace phonebook-system \ 40 | --create-namespace \ 41 | ``` 42 | -------------------------------------------------------------------------------- /Dockerfile.controller: -------------------------------------------------------------------------------- 1 | # Build the controller binary 2 | FROM golang:1.23 AS source 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | ARG PROVIDER_VERSION 6 | 7 | WORKDIR /workspace 8 | # Copy the Go Modules manifests 9 | COPY go.mod go.mod 10 | COPY go.sum go.sum 11 | # cache deps before building and copying source so that we don't need to re-download as much 12 | # and so that source changes don't invalidate our downloaded layer 13 | RUN go mod download 14 | 15 | 16 | FROM source AS controller-builder 17 | 18 | COPY api/ api/ 19 | COPY pkg/ pkg/ 20 | COPY internal/ internal/ 21 | COPY cmd/controller/main.go cmd/main.go 22 | 23 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags="-X 'github.com/pier-oliviert/phonebook/pkg/providers.ProviderVersion=${PROVIDER_VERSION}'" -a -o controller cmd/main.go 24 | 25 | 26 | # Use distroless as minimal base image to package the controller binary 27 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 28 | FROM gcr.io/distroless/static:nonroot AS controller 29 | WORKDIR / 30 | COPY --from=controller-builder /workspace/controller . 31 | USER 65532:65532 32 | 33 | EXPOSE 4443 34 | 35 | ENTRYPOINT ["/controller"] 36 | 37 | -------------------------------------------------------------------------------- /charts/templates/rbac/controller/role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: phonebook:controller 5 | labels: {{- include "operator.labels" . | nindent 4 }} 6 | rules: 7 | - apiGroups: 8 | - se.quencer.io 9 | resources: 10 | - dnsrecords 11 | - dnsintegrations 12 | verbs: 13 | - create 14 | - delete 15 | - get 16 | - list 17 | - patch 18 | - update 19 | - watch 20 | - apiGroups: 21 | - se.quencer.io 22 | resources: 23 | - dnsrecords/finalizers 24 | - dnsintegrations/finalizers 25 | verbs: 26 | - update 27 | - apiGroups: 28 | - se.quencer.io 29 | resources: 30 | - dnsrecords/status 31 | - dnsintegrations/status 32 | verbs: 33 | - get 34 | - patch 35 | - update 36 | - apiGroups: 37 | - "apps" 38 | resources: 39 | - deployments 40 | verbs: 41 | - create 42 | - delete 43 | - get 44 | - list 45 | - patch 46 | - update 47 | - watch 48 | - apiGroups: 49 | - "" 50 | resources: 51 | - events 52 | verbs: 53 | - create 54 | - patch 55 | -------------------------------------------------------------------------------- /cmd/providers/aws/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | "context" 21 | 22 | "github.com/pier-oliviert/phonebook/pkg/providers/aws" 23 | "github.com/pier-oliviert/phonebook/pkg/server" 24 | "sigs.k8s.io/controller-runtime/pkg/log" 25 | ) 26 | 27 | func main() { 28 | var err error 29 | 30 | ctx := context.Background() 31 | logger := log.FromContext(ctx) 32 | 33 | logger.Info("Initializing AWS Client") 34 | p, err := aws.NewClient(ctx) 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | srv := server.NewServer(p) 40 | if err := srv.Run(); err != nil { 41 | panic(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cmd/providers/azure/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | "context" 21 | 22 | "github.com/pier-oliviert/phonebook/pkg/providers/azure" 23 | "github.com/pier-oliviert/phonebook/pkg/server" 24 | "sigs.k8s.io/controller-runtime/pkg/log" 25 | ) 26 | 27 | func main() { 28 | var err error 29 | 30 | ctx := context.Background() 31 | logger := log.FromContext(ctx) 32 | 33 | logger.Info("Initializing Azure Client") 34 | p, err := azure.NewClient(ctx) 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | srv := server.NewServer(p) 40 | if err := srv.Run(); err != nil { 41 | panic(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cmd/providers/cloudflare/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | "context" 21 | 22 | "github.com/pier-oliviert/phonebook/pkg/providers/cloudflare" 23 | "github.com/pier-oliviert/phonebook/pkg/server" 24 | "sigs.k8s.io/controller-runtime/pkg/log" 25 | ) 26 | 27 | func main() { 28 | var err error 29 | 30 | ctx := context.Background() 31 | logger := log.FromContext(ctx) 32 | 33 | logger.Info("Initializing Cloudflare Client") 34 | p, err := cloudflare.NewClient(ctx) 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | srv := server.NewServer(p) 40 | if err := srv.Run(); err != nil { 41 | panic(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/integrations/desec/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'deSEC' 3 | date: 2024-09-27T10:38:15-04:00 4 | draft: false 5 | weight: 1 6 | --- 7 | 8 | ## Example DNSIntegration records 9 | 10 | Create a DNSIntegration to start using your deSEC zone with Phonebook 11 | 12 | You will need to create a deSEC API token with relevant permissions. 13 | 14 | ```yaml 15 | apiVersion: se.quencer.io/v1alpha1 16 | kind: DNSIntegration 17 | metadata: 18 | name: desec 19 | spec: 20 | provider: 21 | name: desec 22 | zones: 23 | - mydomain.com 24 | secretRef: 25 | name: desec-secrets 26 | keys: 27 | - name: "DESEC_TOKEN" 28 | key: 'sometokenfromdesechere' 29 | ``` 30 | 31 | If you wish to use environment variables over secrets: 32 | ```yaml 33 | apiVersion: se.quencer.io/v1alpha1 34 | kind: DNSIntegration 35 | metadata: 36 | name: desec 37 | spec: 38 | provider: 39 | name: desec 40 | zones: 41 | - mydomain.com 42 | env: 43 | - name: DESEC_TOKEN 44 | value: 'sometokenfromdesechere' 45 | ``` 46 | 47 | ## Deploying 48 | 49 | Now you can deploy with the normal command: 50 | ``` 51 | helm upgrade --install phonebook phonebook/phonebook \ 52 | --namespace phonebook-system \ 53 | --create-namespace \ 54 | --values values.yaml 55 | ``` 56 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 v1alpha1 contains API Schema definitions for the se.quencer.io v1alpha1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=se.quencer.io 20 | package v1alpha1 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: "se.quencer.io", Version: "v1alpha1"} 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 | -------------------------------------------------------------------------------- /charts/templates/rbac/controller/bindings.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | {{- include "operator.labels" . | nindent 4 }} 7 | name: phonebook:leader-election 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: Role 11 | name: phonebook:leader-election 12 | subjects: 13 | - kind: ServiceAccount 14 | name: phonebook-controller 15 | namespace: {{ .Release.Namespace }} 16 | --- 17 | apiVersion: rbac.authorization.k8s.io/v1 18 | kind: ClusterRoleBinding 19 | metadata: 20 | labels: 21 | app.kubernetes.io/name: phonebook 22 | {{- include "operator.labels" . | nindent 4 }} 23 | 24 | name: phonebook:controller 25 | roleRef: 26 | apiGroup: rbac.authorization.k8s.io 27 | kind: ClusterRole 28 | name: phonebook:controller 29 | subjects: 30 | - kind: ServiceAccount 31 | name: phonebook-controller 32 | namespace: {{ .Release.Namespace }} 33 | --- 34 | apiVersion: rbac.authorization.k8s.io/v1 35 | kind: ClusterRoleBinding 36 | metadata: 37 | labels: 38 | app.kubernetes.io/name: phonebook 39 | {{- include "operator.labels" . | nindent 4 }} 40 | 41 | name: phonebook:metrics 42 | roleRef: 43 | apiGroup: rbac.authorization.k8s.io 44 | kind: ClusterRole 45 | name: phonebook:metrics 46 | subjects: 47 | - kind: ServiceAccount 48 | name: phonebook-controller 49 | namespace: {{ .Release.Namespace }} 50 | -------------------------------------------------------------------------------- /charts/templates/rbac/providers/bindings.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: phonebook 6 | {{- include "operator.labels" . | nindent 4 }} 7 | 8 | name: phonebook:providers 9 | roleRef: 10 | apiGroup: rbac.authorization.k8s.io 11 | kind: ClusterRole 12 | name: phonebook:providers 13 | subjects: 14 | - kind: ServiceAccount 15 | name: phonebook-providers 16 | namespace: {{ .Release.Namespace }} 17 | --- 18 | apiVersion: rbac.authorization.k8s.io/v1 19 | kind: RoleBinding 20 | metadata: 21 | labels: 22 | app.kubernetes.io/name: phonebook 23 | {{- include "operator.labels" . | nindent 4 }} 24 | name: phonebook:providers-leader-election 25 | roleRef: 26 | apiGroup: rbac.authorization.k8s.io 27 | kind: Role 28 | name: phonebook:providers-leader-election 29 | subjects: 30 | - kind: ServiceAccount 31 | name: phonebook-providers 32 | namespace: {{ .Release.Namespace }} 33 | --- 34 | apiVersion: rbac.authorization.k8s.io/v1 35 | kind: ClusterRoleBinding 36 | metadata: 37 | labels: 38 | app.kubernetes.io/name: phonebook 39 | {{- include "operator.labels" . | nindent 4 }} 40 | 41 | name: phonebook:providers-events 42 | roleRef: 43 | apiGroup: rbac.authorization.k8s.io 44 | kind: ClusterRole 45 | name: phonebook:providers-events 46 | subjects: 47 | - kind: ServiceAccount 48 | name: phonebook-providers 49 | namespace: {{ .Release.Namespace }} 50 | -------------------------------------------------------------------------------- /pkg/providers/provider.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 9 | ) 10 | 11 | // This constant needs to be configured through a build flag when Phonebook is released 12 | var ProviderVersion = "0.0.0" 13 | 14 | type ProviderStore struct { 15 | mu sync.Mutex 16 | provider Provider 17 | } 18 | 19 | func (ps *ProviderStore) Store(p Provider) { 20 | ps.mu.Lock() 21 | defer ps.mu.Unlock() 22 | ps.provider = p 23 | } 24 | 25 | func (ps *ProviderStore) Provider() Provider { 26 | ps.mu.Lock() 27 | defer ps.mu.Unlock() 28 | 29 | return ps.provider 30 | } 31 | 32 | type Provider interface { 33 | Configure(ctx context.Context, integration string, zones []string) error 34 | 35 | // Create a DNS Record 36 | Create(context.Context, phonebook.DNSRecord, phonebook.StagingUpdater) error 37 | 38 | // Delete a DNS Record 39 | Delete(context.Context, phonebook.DNSRecord, phonebook.StagingUpdater) error 40 | 41 | // Zones the Provider has authority over 42 | Zones() []string 43 | } 44 | 45 | var ProviderImages = map[string]string{ 46 | "aws": fmt.Sprintf("ghcr.io/pier-oliviert/providers-aws:v%s", ProviderVersion), 47 | "azure": fmt.Sprintf("ghcr.io/pier-oliviert/providers-azure:v%s", ProviderVersion), 48 | "cloudflare": fmt.Sprintf("ghcr.io/pier-oliviert/providers-cloudflare:v%s", ProviderVersion), 49 | "desec": fmt.Sprintf("ghcr.io/pier-oliviert/providers-desec:v%s", ProviderVersion), 50 | "gcore": fmt.Sprintf("ghcr.io/pier-oliviert/providers-gcore:v%s", ProviderVersion), 51 | } 52 | -------------------------------------------------------------------------------- /test/samples/cert.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1 2 | kind: Certificate 3 | metadata: 4 | name: example-com 5 | namespace: phonebook-system 6 | spec: 7 | # Secret names are always required. 8 | secretName: example-com-tls 9 | 10 | privateKey: 11 | algorithm: RSA 12 | encoding: PKCS1 13 | size: 2048 14 | 15 | duration: 2160h # 90d 16 | renewBefore: 360h # 15d 17 | 18 | isCA: false 19 | usages: 20 | - server auth 21 | - client auth 22 | 23 | subject: 24 | organizations: 25 | - cert-manager 26 | 27 | commonName: yourdomain.com 28 | 29 | # The literalSubject field is exclusive with subject and commonName. It allows 30 | # specifying the subject directly as a string. This is useful for when the order 31 | # of the subject fields is important or when the subject contains special types 32 | # which can be specified by their OID. 33 | # 34 | # literalSubject: "O=jetstack, CN=example.com, 2.5.4.42=John, 2.5.4.4=Doe" 35 | 36 | # At least one of commonName (possibly through literalSubject), dnsNames, uris, emailAddresses, ipAddresses or otherNames is required. 37 | dnsNames: 38 | - "yourdomain.com" 39 | - "*.yourdomain.com" 40 | 41 | # Issuer references are always required. 42 | issuerRef: 43 | name: phonebook-acme-issuer 44 | # We can reference ClusterIssuers by changing the kind here. 45 | # The default value is Issuer (i.e. a locally namespaced Issuer) 46 | kind: ClusterIssuer 47 | # This is optional since cert-manager will default to this value however 48 | # if you are using an external issuer, change this to that issuer group. 49 | group: cert-manager.io 50 | -------------------------------------------------------------------------------- /api/v1alpha1/references/reference.go: -------------------------------------------------------------------------------- 1 | package references 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/apimachinery/pkg/types" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | ) 9 | 10 | // Reference is used to create untyped references to different object 11 | // that needs to be tracked inside of Custom Resources. 12 | // Examples can be found in Workspace & Build where for workspace, 13 | // it needs to reference a build or a pod and uses this struct as a way 14 | // to serialize the labels of the underlying resource. 15 | // +kubebuilder:object:generate=true 16 | type Reference struct { 17 | // `namespace` is the namespace of the resource. 18 | // Required 19 | Namespace string `json:"namespace" protobuf:"bytes,1,opt,name=namespace"` 20 | // `name` is the name of the resourec. 21 | // Required 22 | Name string `json:"name" protobuf:"bytes,2,opt,name=name"` 23 | } 24 | 25 | // Returns a new Reference object, the client.Object interface 26 | // is global and any core resource defined by K8s(Pod, services, etc) as well 27 | // as CRD (Build, Workspace, etc) implements this interface. 28 | // 29 | // NOTE: Since this reference is untyped, different type could, in theory, share the same 30 | // namespace/name and could cause issues. This is why it's important to use generatedName() 31 | // when creating resources internally. 32 | func NewReference(obj client.Object) *Reference { 33 | return &Reference{ 34 | Namespace: obj.GetNamespace(), 35 | Name: obj.GetName(), 36 | } 37 | } 38 | 39 | func (r Reference) String() string { 40 | return fmt.Sprintf("%s/%s", r.Namespace, r.Name) 41 | } 42 | 43 | func (r Reference) NamespacedName() types.NamespacedName { 44 | return types.NamespacedName{ 45 | Name: r.Name, 46 | Namespace: r.Namespace, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /charts/templates/solver/certs.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.solver.enabled }} 2 | --- 3 | apiVersion: cert-manager.io/v1 4 | kind: Issuer 5 | metadata: 6 | name: phonebook-solver-selfsign 7 | namespace: {{ .Release.Namespace }} 8 | labels: 9 | se.quencer.io/solver: phonebook-solver 10 | {{- include "operator.labels" . | nindent 4 }} 11 | spec: 12 | selfSigned: {} 13 | 14 | --- 15 | 16 | apiVersion: cert-manager.io/v1 17 | kind: Certificate 18 | metadata: 19 | name: phonebook-solver-ca 20 | namespace: {{ .Release.Namespace }} 21 | labels: 22 | se.quencer.io/solver: phonebook-solver 23 | {{- include "operator.labels" . | nindent 4 }} 24 | spec: 25 | secretName: phonebook-solver-ca 26 | duration: 43800h # 5y 27 | issuerRef: 28 | name: phonebook-solver-selfsign 29 | commonName: "ca.phonebook.se.quencer.io" 30 | isCA: true 31 | 32 | --- 33 | 34 | apiVersion: cert-manager.io/v1 35 | kind: Issuer 36 | metadata: 37 | name: phonebook-solver-ca 38 | namespace: {{ .Release.Namespace}} 39 | labels: 40 | se.quencer.io/solver: phonebook-solver 41 | {{- include "operator.labels" . | nindent 4 }} 42 | spec: 43 | ca: 44 | secretName: phonebook-solver-ca 45 | 46 | --- 47 | 48 | apiVersion: cert-manager.io/v1 49 | kind: Certificate 50 | metadata: 51 | name: phonebook-solver 52 | namespace: {{ .Release.Namespace }} 53 | labels: 54 | se.quencer.io/solver: phonebook-solver 55 | {{- include "operator.labels" . | nindent 4 }} 56 | spec: 57 | secretName: phonebook-solver 58 | duration: 8760h # 1y 59 | issuerRef: 60 | name: phonebook-solver-ca 61 | dnsNames: 62 | - solver 63 | - solver.{{ .Release.Namespace}} 64 | - solver.{{ .Release.Namespace }}.svc 65 | - solver.{{ .Release.Namespace }}.svc.cluster.local 66 | 67 | {{- end }} 68 | -------------------------------------------------------------------------------- /docs/integrations/create/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Create a provider' 3 | date: 2024-10-18T10:38:15-04:00 4 | draft: false 5 | weight: 100 6 | --- 7 | 8 | The `DNSIntegration` CRD was created to allow anyone to create their own provider and manage DNSRecord using Phonebook. Here you will find all the information to create your own provider. 9 | 10 | ## Concepts 11 | 12 | To understand how integrations work, it's best to visualize Phonebook as a disjointed Kubernetes Operator. Each integration runs a deployment that register a new operator that will listen for `DNSRecord` and only act on the ones that fits their integration profile. 13 | 14 | Phonebook's main controller has the responsibility of validating new DNSRecord as well as ensuring that records can be safely garbage collected when deleted. All the actual operations between a DNSRecord and a DNS Provider is done through the DNSIntegration's deployment. 15 | 16 | ## A Provider's main function 17 | 18 | ```go {filename="main.go"} 19 | package main 20 | 21 | import ( 22 | "context" 23 | 24 | "github.com/pier-oliviert/phonebook/pkg/providers/cloudflare" 25 | "github.com/pier-oliviert/phonebook/pkg/server" 26 | "sigs.k8s.io/controller-runtime/pkg/log" 27 | ) 28 | 29 | func main() { 30 | var err error 31 | 32 | ctx := context.Background() 33 | logger := log.FromContext(ctx) 34 | 35 | logger.Info("Initializing My New Client") 36 | 37 | // Replace this with your provider's client 38 | // The client needs to implement the providers.Provider interface 39 | p, err := cloudflare.NewClient(ctx) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | srv := server.NewServer(p) 45 | if err := srv.Run(); err != nil { 46 | panic(err) 47 | } 48 | } 49 | ``` 50 | 51 | The server that Phonebook provides is a fully configured operator. The call `srv.Run()` will block and will then pass off all the request to the client. 52 | -------------------------------------------------------------------------------- /api/v1alpha1/references/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2024. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package references 22 | 23 | import () 24 | 25 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 26 | func (in *Reference) DeepCopyInto(out *Reference) { 27 | *out = *in 28 | } 29 | 30 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Reference. 31 | func (in *Reference) DeepCopy() *Reference { 32 | if in == nil { 33 | return nil 34 | } 35 | out := new(Reference) 36 | in.DeepCopyInto(out) 37 | return out 38 | } 39 | 40 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 41 | func (in *SecretKey) DeepCopyInto(out *SecretKey) { 42 | *out = *in 43 | } 44 | 45 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKey. 46 | func (in *SecretKey) DeepCopy() *SecretKey { 47 | if in == nil { 48 | return nil 49 | } 50 | out := new(SecretKey) 51 | in.DeepCopyInto(out) 52 | return out 53 | } 54 | 55 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 56 | func (in *SecretRef) DeepCopyInto(out *SecretRef) { 57 | *out = *in 58 | if in.Keys != nil { 59 | in, out := &in.Keys, &out.Keys 60 | *out = make([]SecretKey, len(*in)) 61 | copy(*out, *in) 62 | } 63 | } 64 | 65 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. 66 | func (in *SecretRef) DeepCopy() *SecretRef { 67 | if in == nil { 68 | return nil 69 | } 70 | out := new(SecretRef) 71 | in.DeepCopyInto(out) 72 | return out 73 | } 74 | -------------------------------------------------------------------------------- /docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Phonebook' 3 | date: 2024-09-20T10:38:15-04:00 4 | draft: false 5 | cascade: 6 | type: docs 7 | --- 8 | 9 | Phonebook is a Kubernetes Operator that lets you manage DNS Record like any other resource in Kubernetes -- Deployments, Services, etc. You can safely create and delete DNS Record from `kubectl` and it will do the right thing. 10 | 11 | ```yaml 12 | # This will create a new `A` record `helloworld.gotta-be-kidding.com` pointing 13 | # at `127.0.0.1`` 14 | apiVersion: se.quencer.io/v1alpha1 15 | kind: DNSRecord 16 | metadata: 17 | name: dnsrecord-sample 18 | namespace: phonebook-system 19 | spec: 20 | zone: gotta-be-kidding.com 21 | recordType: A 22 | name: helloworld 23 | targets: 24 | - 127.0.0.1 25 | - 127.0.0.2 # If provider supports multi-target 26 | ``` 27 | 28 | ![A DNS Record](status.png) 29 | 30 | ## Features 31 | 32 | - Only manage DNS Record that are presents as DNSRecord in the cluster 33 | - Manage DNS Record like any other resources (Create/Delete) 34 | - Support all DNS Record Types (A, AAAA, TXT, CNAME, etc.) 35 | - Support cloud provider specific properties 36 | - Proper error handling per DNS Record 37 | - Generate wildcard SSL Certificate with Cert-Manager (Let's Encrypt) 38 | - Allows specifying TTL 39 | - Allows multiple targets on providers with multi support (Azure, AWS) 40 | - Split-Horizon DNS 41 | - Support mutiple, concurrent DNS Provider 42 | 43 | ## Providers 44 | 45 | - Cloudflare 46 | - AWS 47 | - Azure 48 | 49 | Phonebook is built to be cloud agnostic with the goal to support as many cloud providers as [external-dns](https://github.com/kubernetes-sigs/external-dns). Obviously, the list is long and each integration requires efforts to support. If you'd like to have support for your provider, please create an [issue](https://github.com/pier-oliviert/phonebook/issues/new)! 50 | 51 | The [integration]({{< ref "/integrations" >}}) section offers documentation for each of the supported provider. 52 | 53 | ## SSL Certificates 54 | 55 | Any domain managed by Phonebook can be used to generate SSL Certificates using Cert-Manager with Let's Encrypt. Phonebook comes with a [DNS-01 Solver](https://cert-manager.io/docs/configuration/acme/dns01/webhook/) for Cert-Manager which means you can dynamically create SSL Certificates (wildcard included!). Learn how to set up cert-manager with Phonebook [here]({{< ref "/dns-01" >}}). 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phonebook: Manage DNS Record in Kubernetes 2 | [![Tests](https://github.com/pier-oliviert/phonebook/actions/workflows/test.yaml/badge.svg)](https://github.com/pier-oliviert/phonebook/actions/workflows/test.yaml) 3 | 4 | Phonebook is an [operator](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) that helps you manage DNS Record for your cloud provider from within Kubernetes. Using custom resource definitions (CRDs), you can build DNS records in a same manner you would create other resources with Kubernetes. 5 | 6 | ```yaml 7 | # This will create a new `A` record `mysubdomain.mytestdomain.com` pointing 8 | # at `127.0.0.1`` 9 | apiVersion: se.quencer.io/v1alpha1 10 | kind: DNSRecord 11 | metadata: 12 | name: dnsrecord-sample 13 | namespace: phonebook-system 14 | spec: 15 | zone: mytestdomain.com 16 | recordType: A 17 | name: mysubdomain 18 | ttl: 60 19 | targets: 20 | - 127.0.0.1 21 | - 127.0.0.2 # If provider supports multi-target 22 | ``` 23 | 24 | ### Features 25 | 26 | - Only manage DNS Record that are presents as DNSRecord in the cluster 27 | - Manage DNS Record like any other resources (Create/Delete) 28 | - Support all DNS Record Types (A, AAAA, TXT, CNAME, etc.) 29 | - Support cloud provider specific properties 30 | - Proper error handling per DNS Record 31 | - Allows specifying TTL 32 | - Allows multiple targets on providers with multi support (Azure, AWS) 33 | ### Supported providers 34 | 35 | Here's a list of all supported providers. If you need a provider that isn't yet supported, create a new [issue](https://github.com/pier-oliviert/phonebook/issues/new). 36 | 37 | ||||| 38 | |--|--|--|--| 39 | |[AWS](https://pier-oliviert.github.io/phonebook/providers/aws/)|[Cloudflare](https://pier-oliviert.github.io/phonebook/providers/cloudflare/)|[Azure](https://pier-oliviert.github.io/phonebook/providers/azure/)|[deSEC](https://pier-oliviert.github.io/phonebook/providers/desec/) 40 | 41 | ### Get Started 42 | 43 | The [documentation](https://pier-oliviert.github.io/phonebook/) has all the information for you to get started with Phonebook. 44 | 45 | ### Special thanks 46 | 47 | This project was built out of need, but I also want to give a special thanks to [external-dns](https://github.com/kubernetes-sigs/external-dns) as that project was a huge inspiration for Phonebook. A lot of the ideas here stem from my usage of external-dns over the years. I have nothing but respect for that project. 48 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/tasks/integrations/health.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/pier-oliviert/konditionner/pkg/konditions" 8 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 9 | "github.com/pier-oliviert/phonebook/api/v1alpha1/integrations" 10 | apps "k8s.io/api/apps/v1" 11 | core "k8s.io/api/core/v1" 12 | "k8s.io/apimachinery/pkg/labels" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | "sigs.k8s.io/controller-runtime/pkg/log" 15 | ) 16 | 17 | type health struct { 18 | ctx context.Context 19 | integration *phonebook.DNSIntegration 20 | client.Client 21 | } 22 | 23 | func HealthTask(ctx context.Context, c client.Client, integration *phonebook.DNSIntegration) konditions.Task { 24 | t := health{ 25 | ctx: ctx, 26 | integration: integration, 27 | Client: c, 28 | } 29 | 30 | return t.Run 31 | } 32 | 33 | func (t health) Run(condition konditions.Condition) (konditions.Condition, error) { 34 | var deployments apps.DeploymentList 35 | var err error 36 | var label labels.Selector 37 | 38 | label, err = labels.Parse(fmt.Sprintf("%s=%s", integrations.DeploymentLabel, t.integration.Name)) 39 | if err != nil { 40 | return condition, fmt.Errorf("PB#0006: Could not parse the label selector: %w", err) 41 | } 42 | 43 | err = t.List(t.ctx, &deployments, &client.ListOptions{ 44 | LabelSelector: label, 45 | Namespace: t.integration.Namespace, 46 | }) 47 | if err != nil { 48 | goto Done 49 | } 50 | 51 | if deployments.Size() == 0 { 52 | condition.Status = konditions.ConditionStatus("Waiting") 53 | condition.Reason = "Waiting for deployment to be available" 54 | goto Done 55 | } 56 | // Although there should always only be one deployment for a given integration, the call 57 | // to retrieve deployments with labels returns a list, so let's pretend there's more than one 58 | // and if there's an unhealthy deployment, stop the loop right there and then. 59 | for _, d := range deployments.Items { 60 | log.FromContext(t.ctx).Info("Deployment", "Deployment", d) 61 | for _, c := range d.Status.Conditions { 62 | log.FromContext(t.ctx).Info("Condition", "Condition", c) 63 | if c.Status != core.ConditionTrue { 64 | err = fmt.Errorf("PB#0005: Deployment(%s) is not healthy. Check the logs of the pods for more info.", d.Name) 65 | goto Done 66 | } 67 | } 68 | } 69 | 70 | condition.Status = konditions.ConditionCompleted 71 | condition.Reason = "Healthy" 72 | 73 | Done: 74 | return condition, err 75 | } 76 | -------------------------------------------------------------------------------- /docs/integrations/cloudflare/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Cloudflare' 3 | date: 2024-09-20T10:38:15-04:00 4 | draft: false 5 | weight: 1 6 | --- 7 | 8 | ```yaml 9 | apiVersion: se.quencer.io/v1alpha1 10 | kind: DNSIntegration 11 | metadata: 12 | name: cloudflare 13 | spec: 14 | provider: 15 | name: cloudflare 16 | zones: 17 | - mydomain.com 18 | secretRef: 19 | name: cloudflare-secrets 20 | keys: 21 | - key: CF_API_TOKEN 22 | name: CF_API_TOKEN 23 | - key: CF_ZONE_ID 24 | name: CF_ZONE_ID 25 | ``` 26 | 27 | To use Cloudflare as a provider, you'll need to create an API token on their site and create a secret in your Kubernetes cluster. Phonebook expects the secret to live in the **same namespace as the one running Phonebook's controller**. 28 | 29 | ```sh 30 | kubectl create secrets generic cloudflare-secrets \ 31 | --namespace phonebook-system \ 32 | --from-literal=apiToken=${API_TOKEN} \ 33 | --from-literal=zoneId=${ZONE_ID} \ 34 | ``` 35 | 36 |   37 | 38 | ### API Token 39 | 40 | The API Token can be created by going to your [Cloudflare's profile page](https://dash.cloudflare.com/profile/api-tokens). Create a new token that will include the two permissions: 41 | 42 | 1. `Zone.DNS` for `All Zones` 43 | 2. `Account.Cloudflare Tunnel` for `All Account` 44 | 45 | > ![Cloudflare's token page](./token-page.png) 46 | 47 | It's possible to narrow down the zones and accounts to the specific one you want to use, but this is an exercise to the user. Once the API Token is created, you'll need to create a secrets, like the one at the top of this page, that includes your API token as well as the zone id. 48 | 49 |   50 | 51 | ### Zone ID 52 | 53 | ![Domain's page with Zone and Account IDs](profile-page.png) 54 | 55 | ### Tags and comments 56 | 57 | Cloudflare has support for comment and tags for each record. If you want to create a DNS Record that includes both, you can use the `Properties` field of the DNSRecord: 58 | 59 | ```yaml 60 | # This will create a new `A` record `helloworld.gotta-be-kidding.com` pointing 61 | # at `127.0.0.1`` 62 | apiVersion: se.quencer.io/v1alpha1 63 | kind: DNSRecord 64 | metadata: 65 | name: dnsrecord-sample 66 | namespace: phonebook-system 67 | spec: 68 | zone: gotta-be-kidding.com 69 | recordType: A 70 | name: helloworld 71 | targets: 72 | - 127.0.0.1 73 | - 127.0.0.2 # If provider supports multi-target 74 | properties: 75 | comment: "A Comment for this record" 76 | tags: "phonebook;development;user-1" 77 | ``` 78 | 79 | Tags are delimited by the semi-colon character(`;`). You can specify as many as you'd like. 80 | -------------------------------------------------------------------------------- /pkg/providers/gcore/client.go: -------------------------------------------------------------------------------- 1 | package gcore 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | gdns "github.com/G-Core/gcore-dns-sdk-go" 8 | "github.com/pier-oliviert/konditionner/pkg/konditions" 9 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 10 | "github.com/pier-oliviert/phonebook/pkg/utils" 11 | ) 12 | 13 | const ( 14 | EnvAPIToken = "GCORE_API_TOKEN" 15 | DefaultTTL = int64(120) // gcore doesn't support shorter TTL for the free plan, so 120 is the basis to avoid confusions 16 | ) 17 | 18 | // This interface is created so mocks can be done on testing. Since gcore doesn't have any interface to work with, 19 | // this needs to exists here in order for phonebook to have proper testing 20 | type api interface { 21 | AddZoneRRSet(context.Context, string, string, string, []gdns.ResourceRecord, int, ...gdns.AddZoneOpt) error 22 | DeleteRRSet(context.Context, string, string, string) error 23 | } 24 | 25 | type gcore struct { 26 | integration string 27 | zoneID string 28 | zones []string 29 | api api 30 | } 31 | 32 | func NewClient(ctx context.Context) (*gcore, error) { 33 | var err error 34 | 35 | token, err := utils.RetrieveValueFromEnvOrFile(EnvAPIToken) 36 | if err != nil { 37 | return nil, err 38 | } 39 | api := gdns.NewClient(gdns.PermanentAPIKeyAuth(token)) 40 | 41 | return &gcore{ 42 | api: api, 43 | }, err 44 | } 45 | 46 | func (c *gcore) Configure(ctx context.Context, integration string, zones []string) error { 47 | c.zones = zones 48 | c.integration = integration 49 | 50 | return nil 51 | } 52 | 53 | func (c *gcore) Zones() []string { 54 | return c.zones 55 | } 56 | 57 | func (c *gcore) Create(ctx context.Context, record phonebook.DNSRecord, su phonebook.StagingUpdater) error { 58 | values := []gdns.ResourceRecord{{ 59 | Enabled: true, 60 | }} 61 | 62 | // Need to copy each to satisfy the []any types 63 | for _, t := range record.Spec.Targets { 64 | values[0].Content = append(values[0].Content, t) 65 | } 66 | 67 | // TTL is an optional field, so check if it is set before storing the default value 68 | ttl := record.Spec.TTL 69 | if ttl == nil { 70 | ttl = new(int64) 71 | *ttl = DefaultTTL 72 | } 73 | 74 | err := c.api.AddZoneRRSet(ctx, record.Spec.Zone, fmt.Sprintf("%s.%s", record.Spec.Name, record.Spec.Zone), record.Spec.RecordType, values, int(*ttl)) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | su.StageCondition(konditions.ConditionCreated, "G-Core record created") 80 | return nil 81 | } 82 | 83 | func (c *gcore) Delete(ctx context.Context, record phonebook.DNSRecord, su phonebook.StagingUpdater) error { 84 | err := c.api.DeleteRRSet(ctx, record.Spec.Zone, fmt.Sprintf("%s.%s", record.Spec.Name, record.Spec.Zone), record.Spec.RecordType) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | su.StageCondition(konditions.ConditionTerminated, "G-Core record deleted") 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/dnsintegration_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | 22 | . "github.com/onsi/ginkgo/v2" 23 | . "github.com/onsi/gomega" 24 | "k8s.io/apimachinery/pkg/api/errors" 25 | "k8s.io/apimachinery/pkg/types" 26 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 27 | 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | 30 | sequenceriov1alpha1 "github.com/pier-oliviert/phonebook/api/v1alpha1" 31 | ) 32 | 33 | var _ = Describe("DNSIntegration Controller", func() { 34 | Context("When reconciling a resource", func() { 35 | const resourceName = "test-resource" 36 | 37 | ctx := context.Background() 38 | 39 | typeNamespacedName := types.NamespacedName{ 40 | Name: resourceName, 41 | Namespace: "default", 42 | } 43 | dnsprovider := &sequenceriov1alpha1.DNSIntegration{} 44 | 45 | BeforeEach(func() { 46 | By("creating the custom resource for the Kind DNSIntegration") 47 | err := k8sClient.Get(ctx, typeNamespacedName, dnsprovider) 48 | if err != nil && errors.IsNotFound(err) { 49 | resource := &sequenceriov1alpha1.DNSIntegration{ 50 | ObjectMeta: metav1.ObjectMeta{ 51 | Name: resourceName, 52 | Namespace: "default", 53 | }, 54 | Spec: sequenceriov1alpha1.DNSIntegrationSpec{ 55 | Provider: sequenceriov1alpha1.DNSProviderSpec{ 56 | Name: "cloudflare", 57 | }, 58 | Zones: []string{"mydomain.com"}, 59 | }, 60 | } 61 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 62 | } 63 | }) 64 | 65 | AfterEach(func() { 66 | resource := &sequenceriov1alpha1.DNSIntegration{} 67 | err := k8sClient.Get(ctx, typeNamespacedName, resource) 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | By("Cleanup the specific resource instance DNSIntegration") 71 | Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) 72 | }) 73 | It("should successfully reconcile the resource", func() { 74 | By("Reconciling the created resource") 75 | controllerReconciler := &DNSIntegrationReconciler{ 76 | Client: k8sClient, 77 | Scheme: k8sClient.Scheme(), 78 | } 79 | 80 | _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ 81 | NamespacedName: typeNamespacedName, 82 | }) 83 | Expect(err).NotTo(HaveOccurred()) 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /pkg/providers/aws/client_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go-v2/service/route53/types" 10 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 11 | ) 12 | 13 | func TestNewClient(t *testing.T) { 14 | _, err := NewClient(context.TODO()) 15 | if !strings.HasPrefix(err.Error(), "PB-AWS-#0002: Zone ID not found --") { 16 | t.Error("Client should require a Zone ID") 17 | } 18 | 19 | os.Setenv("AWS_ZONE_ID", "Some Value") 20 | } 21 | 22 | func TestDNSNameConcatenation(t *testing.T) { 23 | record := phonebook.DNSRecord{ 24 | Spec: phonebook.DNSRecordSpec{ 25 | Zone: "mydomain.com", 26 | Name: "subdomain", 27 | Targets: []string{"127.0.0.1"}, 28 | }, 29 | } 30 | 31 | c := &r53{ 32 | zoneID: "MyZone123", 33 | } 34 | 35 | set := c.resourceRecordSet(context.TODO(), &record) 36 | 37 | if *set.Name != "subdomain.mydomain.com" { 38 | t.Error("Expected name to include both zone and name", "Name", set.Name) 39 | } 40 | } 41 | 42 | func TestAliastTargetProperty(t *testing.T) { 43 | record := phonebook.DNSRecord{ 44 | Spec: phonebook.DNSRecordSpec{ 45 | Zone: "mydomain.com", 46 | Name: "subdomain", 47 | Targets: []string{"127.0.0.1"}, 48 | Properties: map[string]string{ 49 | AliasTarget: "myTargetZoneID", 50 | }, 51 | }, 52 | } 53 | 54 | c := &r53{ 55 | zoneID: "MyZone123", 56 | } 57 | 58 | set := c.resourceRecordSet(context.TODO(), &record) 59 | 60 | if len(set.ResourceRecords) > 0 { 61 | t.Error("Expected record set to not have any resource records when using AliasTarget", "ResourceRecords", set.ResourceRecords) 62 | } 63 | 64 | if set.AliasTarget == nil { 65 | t.Error("Expected alias target to be set when using AliasTarget") 66 | } 67 | 68 | if *set.AliasTarget.DNSName != record.Spec.Targets[0] { 69 | t.Error("Expected alias target DNSNAme to be set to the target", "Targets", record.Spec.Targets) 70 | } 71 | 72 | if *set.AliasTarget.HostedZoneId != record.Spec.Properties[AliasTarget] { 73 | t.Error("Expected alias hosted zone id to be set to the alias target property", "Properties", record.Spec.Properties) 74 | } 75 | } 76 | 77 | func TestTXTRecord(t *testing.T) { 78 | record := phonebook.DNSRecord{ 79 | Spec: phonebook.DNSRecordSpec{ 80 | RecordType: string(types.RRTypeTxt), 81 | Zone: "mydomain.com", 82 | Name: "subdomain", 83 | Targets: []string{"some-values"}, 84 | }, 85 | } 86 | 87 | c := &r53{ 88 | zoneID: "MyZone123", 89 | } 90 | 91 | set := c.resourceRecordSet(context.TODO(), &record) 92 | 93 | if len(set.ResourceRecords) != 1 { 94 | t.Error("Expected resourceRecordSet to return a single record for this test") 95 | } 96 | 97 | result := set.ResourceRecords[0] 98 | 99 | if *result.Value != "\"some-values\"" { 100 | t.Error("Expected value to return the same value set with extra quotes, got: ", result.Value) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/providers/desec/client.go: -------------------------------------------------------------------------------- 1 | package desec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nrdcg/desec" 8 | "github.com/pier-oliviert/konditionner/pkg/konditions" 9 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 10 | utils "github.com/pier-oliviert/phonebook/pkg/utils" 11 | "sigs.k8s.io/controller-runtime/pkg/log" 12 | ) 13 | 14 | const ( 15 | kDesecToken = "DESEC_TOKEN" 16 | defaultTTL = int64(3600) // Default TTL for DNS records in seconds if not specified 17 | ) 18 | 19 | type deSEC struct { 20 | integration string 21 | token string 22 | client *desec.Client 23 | zones []string 24 | } 25 | 26 | // NewClient initializes a deSEC DNS client 27 | func NewClient(ctx context.Context) (*deSEC, error) { 28 | logger := log.FromContext(ctx) 29 | 30 | token, err := utils.RetrieveValueFromEnvOrFile(kDesecToken) 31 | if err != nil { 32 | return nil, fmt.Errorf("PB-DESEC-#0001: deSEC Token not found -- %w", err) 33 | } 34 | 35 | // Create a new deSEC client with the default options and set the token 36 | options := desec.NewDefaultClientOptions() 37 | client := desec.New(token, options) 38 | 39 | logger.Info("[Provider] deSEC Configured") 40 | 41 | return &deSEC{ 42 | integration: "deSEC", 43 | token: token, 44 | client: client, 45 | }, nil 46 | } 47 | 48 | func (d *deSEC) Configure(ctx context.Context, integration string, zones []string) error { 49 | d.integration = integration 50 | d.zones = zones 51 | 52 | return nil 53 | } 54 | 55 | func (d *deSEC) Zones() []string { 56 | return d.zones 57 | } 58 | 59 | // Create DNS record in deSEC 60 | func (d *deSEC) Create(ctx context.Context, record phonebook.DNSRecord, su phonebook.StagingUpdater) error { 61 | logger := log.FromContext(ctx) 62 | 63 | ttl := defaultTTL 64 | if record.Spec.TTL != nil { 65 | ttl = *record.Spec.TTL 66 | } 67 | 68 | // Create a new RRSet 69 | rrset := desec.RRSet{ 70 | Domain: record.Spec.Zone, 71 | Name: record.Spec.Name, 72 | SubName: record.Spec.Name, 73 | Type: record.Spec.RecordType, 74 | TTL: int(ttl), 75 | Records: record.Spec.Targets, 76 | } 77 | 78 | // Create the RRSet 79 | _, err := d.client.Records.Create(ctx, rrset) 80 | if err != nil { 81 | return fmt.Errorf("PB-DESEC-#0002: Unable to create record -- %w", err) 82 | } 83 | 84 | logger.Info("[Provider] deSEC Record Created") 85 | su.StageCondition(konditions.ConditionCreated, "deSEC record created") 86 | 87 | return nil 88 | } 89 | 90 | // Delete DNS record in deSEC 91 | func (d *deSEC) Delete(ctx context.Context, record phonebook.DNSRecord, su phonebook.StagingUpdater) error { 92 | logger := log.FromContext(ctx) 93 | 94 | // Delete the RRSet 95 | err := d.client.Records.Delete(ctx, record.Spec.Zone, record.Spec.Name, record.Spec.RecordType) 96 | if err != nil { 97 | return fmt.Errorf("PB-DESEC-#0003: Unable to delete record -- %w", err) 98 | } 99 | 100 | logger.Info("[Provider] deSEC Record Deleted") 101 | 102 | su.StageCondition(konditions.ConditionTerminated, "deSEC record deleted") 103 | 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | "path/filepath" 23 | "runtime" 24 | "testing" 25 | 26 | . "github.com/onsi/ginkgo/v2" 27 | . "github.com/onsi/gomega" 28 | 29 | "k8s.io/client-go/kubernetes/scheme" 30 | "k8s.io/client-go/rest" 31 | "sigs.k8s.io/controller-runtime/pkg/client" 32 | "sigs.k8s.io/controller-runtime/pkg/envtest" 33 | logf "sigs.k8s.io/controller-runtime/pkg/log" 34 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 35 | 36 | sequenceriov1alpha1 "github.com/pier-oliviert/phonebook/api/v1alpha1" 37 | // +kubebuilder:scaffold:imports 38 | ) 39 | 40 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 41 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 42 | 43 | var ( 44 | cfg *rest.Config 45 | k8sClient client.Client 46 | testEnv *envtest.Environment 47 | ctx context.Context 48 | cancel context.CancelFunc 49 | ) 50 | 51 | func TestControllers(t *testing.T) { 52 | RegisterFailHandler(Fail) 53 | 54 | RunSpecs(t, "Controller Suite") 55 | } 56 | 57 | var _ = BeforeSuite(func() { 58 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 59 | 60 | ctx, cancel = context.WithCancel(context.TODO()) 61 | 62 | By("bootstrapping test environment") 63 | testEnv = &envtest.Environment{ 64 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "charts", "templates", "crds")}, 65 | ErrorIfCRDPathMissing: true, 66 | 67 | // The BinaryAssetsDirectory is only required if you want to run the tests directly 68 | // without call the makefile target test. If not informed it will look for the 69 | // default path defined in controller-runtime which is /usr/local/kubebuilder/. 70 | // Note that you must have the required binaries setup under the bin directory to perform 71 | // the tests directly. When we run make test it will be setup and used automatically. 72 | BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", 73 | fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), 74 | } 75 | 76 | var err error 77 | // cfg is defined in this file globally. 78 | cfg, err = testEnv.Start() 79 | Expect(err).NotTo(HaveOccurred()) 80 | Expect(cfg).NotTo(BeNil()) 81 | 82 | err = sequenceriov1alpha1.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 | cancel() 95 | err := testEnv.Stop() 96 | Expect(err).NotTo(HaveOccurred()) 97 | }) 98 | -------------------------------------------------------------------------------- /internal/solver/server.go: -------------------------------------------------------------------------------- 1 | package solver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/cert-manager/cert-manager/pkg/acme/webhook" 12 | whapi "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" 13 | "github.com/cert-manager/cert-manager/pkg/acme/webhook/apiserver" 14 | genericapiserver "k8s.io/apiserver/pkg/server" 15 | genericoptions "k8s.io/apiserver/pkg/server/options" 16 | ) 17 | 18 | // Serve requests on port 4443 for the DNS-01 Solver through Kubernete's 19 | // APIService(1) and using self-signed certificates created by Phonebook's helm chart. 20 | // The Service runs through HTTPS but is only used internally by Kubernetes as described by 21 | // cert-manager's documentation on DNS-01 webhooks integration (2). 22 | // 23 | // Most of the code here was copied from Cert-manager's abstraction layer. By default, cert-manager expects the webhook 24 | // to run isolated as its own binary, which means their webhook abstraction deals with arguments parsing and server configuration. 25 | // 26 | // While this may work for other integrations, it caused a bunch of weird issues for Phonebook as the server runs 27 | // inside the main controller. First, the command line arguments conflicts with the ones provided for the controller 28 | // then, the client Phonebook's solver wants to use is different than the one passed by cert-manager's abstraction. 29 | // 30 | // For this reason, a lot of the code was copied over so the Aggregation layer can be properly configured 31 | // with the APIService and Phonebook can still interact with DNSRecord the same way the rest of the operator is. 32 | // 33 | // 1. https://kubernetes.io/docs/tasks/extend-kubernetes/configure-aggregation-layer/ 34 | // 2. https://cert-manager.io/docs/configuration/acme/dns01/webhook/ 35 | func Serve(ctx context.Context, slvr *Solver) error { 36 | ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) 37 | defer stop() 38 | 39 | opts := genericoptions.NewRecommendedOptions( 40 | "", 41 | apiserver.Codecs.LegacyCodec(whapi.SchemeGroupVersion), 42 | ) 43 | 44 | opts.Etcd = nil 45 | opts.Admission = nil 46 | opts.Features.EnablePriorityAndFairness = false 47 | 48 | opts.SecureServing.BindPort = 4443 49 | opts.SecureServing.ServerCert.CertKey = genericoptions.CertKey{ 50 | CertFile: "/tls/tls.crt", 51 | KeyFile: "/tls/tls.key", 52 | } 53 | 54 | if err := opts.SecureServing.MaybeDefaultWithSelfSignedCerts("localhost", nil, []net.IP{net.ParseIP("127.0.0.1")}); err != nil { 55 | return fmt.Errorf("PB-SLV-#0002: error creating self-signed certificates: %v", err) 56 | } 57 | 58 | serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs) 59 | if err := opts.ApplyTo(serverConfig); err != nil { 60 | return fmt.Errorf("PB-SLV-#0002: %w", err) 61 | } 62 | 63 | if errs := opts.Validate(); len(errs) > 0 { 64 | return fmt.Errorf("PB-SLV-#0002: error validating recommended options: %v", errs) 65 | } 66 | 67 | config := &apiserver.Config{ 68 | GenericConfig: serverConfig, 69 | ExtraConfig: apiserver.ExtraConfig{ 70 | SolverGroup: slvr.Group(), 71 | Solvers: []webhook.Solver{slvr}, 72 | }, 73 | } 74 | 75 | server, err := config.Complete().New() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | return server.GenericAPIServer.PrepareRun().RunWithContext(ctx) 81 | } 82 | -------------------------------------------------------------------------------- /Dockerfile.providers: -------------------------------------------------------------------------------- 1 | # Build the controller binary 2 | FROM golang:1.23 AS source 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 | 11 | # cache deps before building and copying source so that we don't need to re-download as much 12 | # and so that source changes don't invalidate our downloaded layer 13 | RUN go mod download 14 | 15 | 16 | ## AWS 17 | FROM source AS aws-builder 18 | 19 | COPY api/ api/ 20 | COPY pkg/ pkg/ 21 | COPY internal/ internal/ 22 | COPY cmd/providers/aws/main.go cmd/main.go 23 | 24 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o controller cmd/main.go 25 | 26 | 27 | # Use distroless as minimal base image to package the controller binary 28 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 29 | FROM gcr.io/distroless/static:nonroot AS aws 30 | WORKDIR / 31 | COPY --from=aws-builder /workspace/controller . 32 | USER 65532:65532 33 | 34 | ENTRYPOINT ["/controller"] 35 | 36 | 37 | ## Azure 38 | FROM source AS azure-builder 39 | 40 | COPY api/ api/ 41 | COPY pkg/ pkg/ 42 | COPY internal/ internal/ 43 | COPY cmd/providers/azure/main.go cmd/main.go 44 | 45 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o controller cmd/main.go 46 | 47 | 48 | # Use distroless as minimal base image to package the controller binary 49 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 50 | FROM gcr.io/distroless/static:nonroot AS azure 51 | WORKDIR / 52 | COPY --from=azure-builder /workspace/controller . 53 | USER 65532:65532 54 | 55 | ENTRYPOINT ["/controller"] 56 | 57 | 58 | ## Cloudflare 59 | 60 | FROM source AS cloudflare-builder 61 | 62 | COPY api/ api/ 63 | COPY pkg/ pkg/ 64 | COPY internal/ internal/ 65 | COPY cmd/providers/cloudflare/main.go cmd/main.go 66 | 67 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o controller cmd/main.go 68 | 69 | 70 | # Use distroless as minimal base image to package the controller binary 71 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 72 | FROM gcr.io/distroless/static:nonroot AS cloudflare 73 | WORKDIR / 74 | COPY --from=cloudflare-builder /workspace/controller . 75 | USER 65532:65532 76 | 77 | ENTRYPOINT ["/controller"] 78 | 79 | 80 | 81 | ## DeSEC 82 | FROM source AS desec-builder 83 | 84 | COPY api/ api/ 85 | COPY pkg/ pkg/ 86 | COPY internal/ internal/ 87 | COPY cmd/providers/desec/main.go cmd/main.go 88 | 89 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o controller cmd/main.go 90 | 91 | FROM gcr.io/distroless/static:nonroot AS desec 92 | WORKDIR / 93 | COPY --from=desec-builder /workspace/controller . 94 | USER 65532:65532 95 | 96 | ENTRYPOINT ["/controller"] 97 | 98 | ## gcore 99 | FROM source AS gcore-builder 100 | 101 | COPY api/ api/ 102 | COPY pkg/ pkg/ 103 | COPY internal/ internal/ 104 | COPY cmd/providers/gcore/main.go cmd/main.go 105 | 106 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o controller cmd/main.go 107 | 108 | FROM gcr.io/distroless/static:nonroot AS gcore 109 | WORKDIR / 110 | COPY --from=gcore-builder /workspace/controller . 111 | USER 65532:65532 112 | 113 | ENTRYPOINT ["/controller"] 114 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "flag" 7 | "fmt" 8 | "strings" 9 | 10 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 11 | // to ensure that exec-entrypoint and run can make use of them. 12 | 13 | _ "k8s.io/client-go/plugin/pkg/client/auth" 14 | "k8s.io/utils/env" 15 | 16 | "k8s.io/apimachinery/pkg/runtime" 17 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 18 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 19 | ctrl "sigs.k8s.io/controller-runtime" 20 | "sigs.k8s.io/controller-runtime/pkg/healthz" 21 | "sigs.k8s.io/controller-runtime/pkg/log" 22 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 23 | 24 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 25 | reconcilers "github.com/pier-oliviert/phonebook/internal/reconcilers/provider" 26 | "github.com/pier-oliviert/phonebook/pkg/providers" 27 | ) 28 | 29 | var scheme = runtime.NewScheme() 30 | 31 | func init() { 32 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 33 | utilruntime.Must(phonebook.AddToScheme(scheme)) 34 | } 35 | 36 | type Server interface { 37 | Run() error 38 | } 39 | 40 | func NewServer(p providers.Provider) Server { 41 | s := &server{} 42 | s.Store(p) 43 | return s 44 | } 45 | 46 | type server struct { 47 | providers.ProviderStore 48 | } 49 | 50 | func (s *server) Run() error { 51 | var tlsOpts []func(*tls.Config) 52 | 53 | opts := zap.Options{ 54 | Development: true, 55 | } 56 | opts.BindFlags(flag.CommandLine) 57 | flag.Parse() 58 | 59 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 60 | logger := log.FromContext(context.Background()) 61 | 62 | disableHTTP2 := func(c *tls.Config) { 63 | logger.Info("disabling http/2") 64 | c.NextProtos = []string{"http/1.1"} 65 | } 66 | 67 | tlsOpts = append(tlsOpts, disableHTTP2) 68 | integration := env.GetString("PB_INTEGRATION", "") 69 | zones := strings.Split(env.GetString("PB_ZONES", ""), ",") 70 | 71 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 72 | Scheme: scheme, 73 | HealthProbeBindAddress: ":8081", 74 | LeaderElection: true, 75 | LeaderElectionID: fmt.Sprintf("%s-provider.phonebook.se.quencer.io", integration), 76 | LeaderElectionReleaseOnCancel: true, 77 | }) 78 | if err != nil { 79 | return fmt.Errorf("PB#0004: Unable to start manager -- %w", err) 80 | } 81 | 82 | if err = s.Provider().Configure(context.Background(), integration, zones); err != nil { 83 | // Error coming from a Provider should already be coded, so returning it as is. 84 | return err 85 | } 86 | 87 | if err = (&reconcilers.ProviderReconciler{ 88 | Integration: integration, 89 | Store: &s.ProviderStore, 90 | Client: mgr.GetClient(), 91 | Scheme: mgr.GetScheme(), 92 | EventRecorder: mgr.GetEventRecorderFor("dnsrecord"), 93 | }).SetupWithManager(mgr); err != nil { 94 | return fmt.Errorf("PB#0004: Unable to create controller -- %w", err) 95 | } 96 | 97 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 98 | return fmt.Errorf("PB#0004: Unable to set up health check -- %w", err) 99 | } 100 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 101 | return fmt.Errorf("PB#0004: Unable to set up ready check -- %w", err) 102 | } 103 | 104 | logger.Info("starting manager") 105 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 106 | return fmt.Errorf("PB#0004: Could not start controller -- %w", err) 107 | } 108 | 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /pkg/providers/gcore/client_test.go: -------------------------------------------------------------------------------- 1 | package gcore 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | gdns "github.com/G-Core/gcore-dns-sdk-go" 10 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 11 | "github.com/pier-oliviert/phonebook/pkg/mocks" 12 | "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | type MockRecordSetsClient struct { 16 | mock.Mock 17 | recordCreated rCreated 18 | recordDeleted rDeleted 19 | } 20 | 21 | type rCreated struct { 22 | zone string 23 | name string 24 | recordType string 25 | ttl int 26 | } 27 | 28 | type rDeleted struct { 29 | zone string 30 | name string 31 | recordType string 32 | } 33 | 34 | func (m *MockRecordSetsClient) AddZoneRRSet(_ context.Context, zone, name, rType string, values []gdns.ResourceRecord, ttl int, _ ...gdns.AddZoneOpt) error { 35 | m.recordCreated = rCreated{ 36 | zone: zone, 37 | name: name, 38 | recordType: rType, 39 | ttl: ttl, 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (m *MockRecordSetsClient) DeleteRRSet(_ context.Context, zone, name, rType string) error { 46 | m.recordDeleted = rDeleted{ 47 | zone: zone, 48 | name: name, 49 | recordType: rType, 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func TestNewClient(t *testing.T) { 56 | os.Setenv("GCORE_API_TOKEN", "Mytoken") 57 | _, err := NewClient(context.Background()) 58 | if err != nil { 59 | t.Error(err) 60 | } 61 | } 62 | 63 | func TestCreation(t *testing.T) { 64 | os.Setenv("GCORE_API_TOKEN", "Mytoken") 65 | client, err := NewClient(context.Background()) 66 | if err != nil { 67 | t.Error(err) 68 | } 69 | 70 | mock := &MockRecordSetsClient{} 71 | client.api = mock 72 | 73 | // Prepare test DNS record 74 | record := phonebook.DNSRecord{ 75 | Spec: phonebook.DNSRecordSpec{ 76 | Zone: "mydomain.com", 77 | Name: "subdomain", 78 | RecordType: "A", 79 | Targets: []string{"127.0.0.1"}, 80 | }, 81 | Status: phonebook.DNSRecordStatus{}, 82 | } 83 | 84 | err = client.Create(context.Background(), record, &mocks.Updater{}) 85 | if err != nil { 86 | t.Error(err) 87 | } 88 | 89 | if mock.recordCreated.zone != record.Spec.Zone { 90 | t.Errorf("Record did not match zone %s: %s", mock.recordCreated.zone, record.Spec.Zone) 91 | } 92 | 93 | if mock.recordCreated.name != fmt.Sprintf("%s.%s", record.Spec.Name, record.Spec.Zone) { 94 | t.Errorf("Record did not match name %s: %s.%s", mock.recordCreated.name, record.Spec.Zone, record.Spec.Zone) 95 | } 96 | 97 | if mock.recordCreated.recordType != record.Spec.RecordType { 98 | t.Errorf("Record did not match record type %s: %s", mock.recordCreated.recordType, record.Spec.RecordType) 99 | } 100 | 101 | if mock.recordCreated.ttl != int(DefaultTTL) { 102 | t.Errorf("Record did not match default TTL %d: %d", mock.recordCreated.ttl, DefaultTTL) 103 | } 104 | } 105 | 106 | func TestCreationTTLSet(t *testing.T) { 107 | os.Setenv("GCORE_API_TOKEN", "Mytoken") 108 | client, err := NewClient(context.Background()) 109 | if err != nil { 110 | t.Error(err) 111 | } 112 | 113 | mock := &MockRecordSetsClient{} 114 | client.api = mock 115 | 116 | // Prepare test DNS record 117 | record := phonebook.DNSRecord{ 118 | Spec: phonebook.DNSRecordSpec{ 119 | Zone: "mydomain.com", 120 | Name: "subdomain", 121 | RecordType: "A", 122 | Targets: []string{"127.0.0.1"}, 123 | TTL: new(int64), 124 | }, 125 | Status: phonebook.DNSRecordStatus{}, 126 | } 127 | *record.Spec.TTL = 900 128 | 129 | err = client.Create(context.Background(), record, &mocks.Updater{}) 130 | if err != nil { 131 | t.Error(err) 132 | } 133 | 134 | if mock.recordCreated.ttl != 900 { 135 | t.Errorf("Record did not match default TTL %d: %d", mock.recordCreated.ttl, 900) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /charts/templates/controller/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: phonebook-controller 5 | namespace: {{ .Release.Namespace }} 6 | labels: 7 | control-plane: phonebook-controller 8 | {{- include "operator.labels" . | nindent 4 }} 9 | spec: 10 | selector: 11 | matchLabels: 12 | control-plane: phonebook-controller 13 | se.quencer.io/solver: phonebook-solver 14 | replicas: 1 15 | template: 16 | metadata: 17 | annotations: 18 | kubectl.kubernetes.io/default-container: controller 19 | labels: 20 | control-plane: phonebook-controller 21 | se.quencer.io/solver: phonebook-solver 22 | {{- include "operator.labels" . | nindent 8 }} 23 | spec: 24 | affinity: 25 | nodeAffinity: 26 | requiredDuringSchedulingIgnoredDuringExecution: 27 | nodeSelectorTerms: 28 | - matchExpressions: 29 | - key: kubernetes.io/arch 30 | operator: In 31 | values: 32 | - amd64 33 | - arm64 34 | - key: kubernetes.io/os 35 | operator: In 36 | values: 37 | - linux 38 | securityContext: 39 | runAsNonRoot: true 40 | seccompProfile: 41 | type: RuntimeDefault 42 | containers: 43 | - command: 44 | - /controller 45 | args: 46 | - --leader-elect 47 | - --health-probe-bind-address=:8081 48 | {{- if .Values.solver.enabled }} 49 | - --solver 50 | {{- end }} 51 | image: {{ (.Values.controller).image | default (include "operator.defaultImage" .) | quote }} 52 | name: controller 53 | env: 54 | - name: PB_PROVIDER_SERVICE_ACC 55 | value: phonebook-providers 56 | - name: PB_NAMESPACE 57 | valueFrom: 58 | fieldRef: 59 | fieldPath: metadata.namespace 60 | {{- range (.Values.controller).env }} 61 | - name: {{ .name | quote }} 62 | value: {{ toYaml .value }} 63 | {{- end }} 64 | {{- range ((.Values.controller).secrets).keys }} 65 | - name: {{ .name | quote }} 66 | valueFrom: 67 | secretKeyRef: 68 | name: {{ required "A secret name needs to be specified" $.Values.controller.secrets.name | quote }} 69 | key: {{ .key | quote }} 70 | {{- end }} 71 | securityContext: 72 | allowPrivilegeEscalation: false 73 | capabilities: 74 | drop: 75 | - "ALL" 76 | livenessProbe: 77 | httpGet: 78 | path: /healthz 79 | port: 8081 80 | initialDelaySeconds: 15 81 | periodSeconds: 20 82 | readinessProbe: 83 | httpGet: 84 | path: /readyz 85 | port: 8081 86 | initialDelaySeconds: 5 87 | periodSeconds: 10 88 | {{- if .Values.solver.enabled }} 89 | ports: 90 | - containerPort: 4443 91 | {{- end }} 92 | resources: 93 | limits: 94 | cpu: 500m 95 | memory: 512Mi 96 | requests: 97 | cpu: 100m 98 | memory: 128Mi 99 | volumeMounts: 100 | {{- if .Values.solver.enabled }} 101 | - name: certs 102 | mountPath: /tls 103 | readOnly: true 104 | {{- end }} 105 | serviceAccountName: phonebook-controller 106 | terminationGracePeriodSeconds: 10 107 | {{- if .Values.solver.enabled }} 108 | volumes: 109 | - name: certs 110 | secret: 111 | secretName: {{ .Values.solver.privateKeySecretRef.name }} 112 | {{- end }} 113 | -------------------------------------------------------------------------------- /docs/integrations/azure/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Azure' 3 | date: 2024-09-27T10:38:15-04:00 4 | draft: false 5 | weight: 1 6 | --- 7 | 8 | ## Obtaining Access to the Azure DNS Zone via Service Principal 9 | 10 | ### Introduction 11 | For the Azure provider to work, you will need to obtain a service principal within Azure, that has permissions to the DNS zone, within your resource group and subscription. 12 | 13 | To create this, you will need the `azure-cli` and `jq` installed. 14 | 15 | This guide assumes you already have an existing DNS zone created within Azure. If you don't, you can create one with the Azure CLI: 16 | 17 | ``` 18 | az group create --name "MyResourceGroupName" --location "uksouth" 19 | az network dns zone create --resource-group "MyResourceGroupName" --name "myphonebookdomain.tld" 20 | ``` 21 | You may wish to substitute the location to a more suitable one nearer to you. 22 | 23 | ### Creating the service principal 24 | 25 | For phonebook to be able to manage Azure DNS records, it requires access of `DNS Zone Contributor`, and Reader to the resource group containing the DNS zones themselves. More permissive levels will also work, but using the principle of least access is highly reccomended. 26 | 27 | To create the service principle and grant permissions, you can run the below: 28 | 29 | ```bash 30 | SP_NAME="MyPhoneBookServicePrincipal" 31 | RG_NAME="MyResourceGroupName" 32 | ZONE_NAME="myphonebookdomain.tld 33 | 34 | SP=$(az ad sp create-for-rbac --name $SP_NAME) 35 | SP_APP_ID=$(echo $SP | jq -r '.appId') 36 | SP_APP_PW=$(echo $DNS_SP | jq -r '.password') 37 | 38 | DNS_ID=$(az network dns zone show --name $ZONE_NAME --resource-group $RG_NAME --query "id" --output tsv) 39 | 40 | az role assignment create --role "Reader" --assignee $SP_APP_ID --scope $DNS_ID 41 | az role assignment create --role "Contributor" --assignee $SP_APP_ID --scope $DNS_ID 42 | 43 | TENANT_ID=$(az account show --query tenantId -o tsv) 44 | SUB_ID=$(az account show --query id -o tsv) 45 | 46 | echo "AZURE_ZONE_NAME = $ZONE_NAME" 47 | echo "AZURE_RESOURCE_GROUP = $RG_NAME" 48 | echo "AZURE_SUBSCRIPTION_ID = $SUB_ID" 49 | echo "AZURE_TENANT_ID = $TENANT_ID" 50 | echo "AZURE_CLIENT_ID = $SP_APP_ID" 51 | echo "AZURE_CLIENT_SECRET = $SP_APP_PW" 52 | ``` 53 | 54 | save the output of this in your preferred secure storage. You cannot retrieve the password post creation. 55 | 56 | ## Example DNSIntegration records 57 | 58 | Create a DNSIntegration to start using your Azure zone with Phonebook 59 | 60 | ```yaml 61 | apiVersion: se.quencer.io/v1alpha1 62 | kind: DNSIntegration 63 | metadata: 64 | name: azure 65 | spec: 66 | provider: 67 | name: azure 68 | zones: 69 | - mydomain.com 70 | secretRef: 71 | name: azure-secrets 72 | keys: 73 | - name: "AZURE_ZONE_NAME" 74 | key: "zoneName" 75 | - name: "AZURE_RESOURCE_GROUP" 76 | key: "rgName" 77 | - name: "AZURE_SUBSCRIPTION_ID" 78 | key: "subId" 79 | - name: "AZURE_TENANT_ID" 80 | key: "tenantId" 81 | - name: "AZURE_CLIENT_ID" 82 | key: "clientId" 83 | - name: "AZURE_CLIENT_SECRET" 84 | key: "clientSecret" 85 | ``` 86 | 87 | If you wish to use environment variables over secrets: 88 | ```yaml 89 | apiVersion: se.quencer.io/v1alpha1 90 | kind: DNSIntegration 91 | metadata: 92 | name: azure 93 | spec: 94 | provider: 95 | name: azure 96 | zones: 97 | - mydomain.com 98 | env: 99 | - name: PHONEBOOK_PROVIDER 100 | value: azure 101 | - name: AZURE_ZONE_NAME 102 | value: zoneName 103 | - name: AZURE_RESOURCE_GROUP 104 | value: rgName 105 | - name: AZURE_SUBSCRIPTION_ID 106 | value: subId 107 | - name: AZURE_CLIENT_ID 108 | value: clientId 109 | - name: AZURE_CLIENT_SECRET 110 | value: clientSecret 111 | - name: AZURE_TENANT_ID 112 | value: tenantId 113 | ``` 114 | 115 | ## Deploying 116 | 117 | Now you can deploy with the normal command: 118 | ``` 119 | helm upgrade --install phonebook phonebook/phonebook \ 120 | --namespace phonebook-system \ 121 | --create-namespace \ 122 | --values values.yaml 123 | ``` 124 | -------------------------------------------------------------------------------- /docs/dns-01/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "SSL Cert with Cert-Manager" 3 | date: 2024-09-30T20:42:38.603Z 4 | draft: false 5 | cascade: 6 | type: docs 7 | --- 8 | 9 | One of the benefit of using Phonebook is that it comes with full support for [Let's Encrypt](https://letsencrypt.org/) DNS-01 Challenge with Cert-Manager. What does that mean for you? 10 | 11 | It means you can create SSL Certificate for any domain you own, **including wildcards Certificates**. Those certificates can also be dynamically [using](https://cert-manager.io/docs/usage/certificate/) [cert-manager's](https://cert-manager.io/docs/usage/ingress/) [annotations](https://cert-manager.io/docs/usage/gateway/). 12 | 13 | ## Configure Cert-Manager 14 | 15 | You'll obviously need to have cert-manager running in your cluster. If you need help to install it, their documentation is pretty thorough: [https://cert-manager.io/docs/installation/](https://cert-manager.io/docs/installation/). Once that's done, you'll need to configure a new Issuer: 16 | 17 | ```yaml 18 | apiVersion: cert-manager.io/v1 19 | kind: ClusterIssuer 20 | metadata: 21 | name: phonebook-acme-issuer 22 | spec: 23 | acme: 24 | email: "youremail@exmaple.com" 25 | server: "https://acme-v02.api.letsencrypt.org/directory" 26 | privateKeySecretRef: 27 | name: acme-issuer 28 | solvers: 29 | - dns01: 30 | webhook: 31 | groupName: phonebook.se.quencer.io 32 | solverName: solver 33 | 34 | ``` 35 | 36 | The `email` field needs to be set to an email adddress **you own**. Once it is set, save your yaml file (ie. `issuer.yaml`) and create the issuer: 37 | 38 | ```bash 39 | kubectl create -f issuer.yaml 40 | ``` 41 | 42 | ## Enable DNS-01 Solver on Phonebook 43 | While the Issuer is fully configured at this point, Phonebook, by default, doesn't have the DNS-01 Solver running. To enable it, you can update your Helm installation: 44 | 45 | ```bash 46 | helm upgrade --install phonebook phonebook/phonebook \ 47 | --namespace phonebook-system \ 48 | --create-namespace \ 49 | --set solver.enabled=true 50 | ``` 51 | 52 | Once this call returns, Phonebook's controller should restart and if you inspect your deployment, you should see that the controller now runs with an extra argument (`--solver`). You should now be ready to create SSL certificate using cert-manager with Let's Encrypt. 53 | 54 | ## Examples 55 | These examples are copies of examples you can find in Cert-Manager's docuemntation pages. The Issuer was changed to the one created above to give you an idea of how you can make it work for you. 56 | 57 | ### Ingress Annotations 58 | 59 | ```yaml 60 | apiVersion: networking.k8s.io/v1 61 | kind: Ingress 62 | metadata: 63 | annotations: 64 | cert-manager.io/cluster-issuer: phonebook-acme-issuer 65 | name: myIngress 66 | namespace: myIngress 67 | spec: 68 | rules: 69 | - host: example.com 70 | http: 71 | paths: 72 | - pathType: Prefix 73 | path: / 74 | backend: 75 | service: 76 | name: myservice 77 | port: 78 | number: 80 79 | tls: # < placing a host in the TLS config will determine what ends up in the cert's subjectAltNames 80 | - hosts: 81 | - example.com 82 | secretName: myingress-cert # < cert-manager will store the created certificate in this secret. 83 | ``` 84 | 85 | ### Certificate 86 | ```yaml 87 | apiVersion: cert-manager.io/v1 88 | kind: Certificate 89 | metadata: 90 | name: example-com 91 | namespace: phonebook-system 92 | spec: 93 | secretName: example-com-tls 94 | 95 | privateKey: 96 | algorithm: RSA 97 | encoding: PKCS1 98 | size: 2048 99 | 100 | duration: 2160h # 90d 101 | renewBefore: 360h # 15d 102 | 103 | isCA: false 104 | usages: 105 | - server auth 106 | - client auth 107 | 108 | subject: 109 | organizations: 110 | - cert-manager 111 | 112 | commonName: mydomain.com 113 | dnsNames: 114 | - "mydomain.com 115 | - "*.mydomain.com" 116 | 117 | # Issuer references are always required. 118 | issuerRef: 119 | name: phonebook-acme-issuer 120 | kind: ClusterIssuer 121 | group: cert-manager.io 122 | ``` 123 | -------------------------------------------------------------------------------- /test/e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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/pier-oliviert/phonebook/test/utils" 28 | ) 29 | 30 | const namespace = "phonebook-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("installing the cert-manager") 38 | Expect(utils.InstallCertManager()).To(Succeed()) 39 | 40 | By("creating manager namespace") 41 | cmd := exec.Command("kubectl", "create", "ns", namespace) 42 | _, _ = utils.Run(cmd) 43 | }) 44 | 45 | AfterAll(func() { 46 | By("uninstalling the Prometheus manager bundle") 47 | utils.UninstallPrometheusOperator() 48 | 49 | By("uninstalling the cert-manager bundle") 50 | utils.UninstallCertManager() 51 | 52 | By("removing manager namespace") 53 | cmd := exec.Command("kubectl", "delete", "ns", namespace) 54 | _, _ = utils.Run(cmd) 55 | }) 56 | 57 | Context("Operator", func() { 58 | It("should run successfully", func() { 59 | var controllerPodName string 60 | var err error 61 | 62 | // projectimage stores the name of the image used in the example 63 | var projectimage = "example.com/phonebook:v0.0.1" 64 | 65 | By("building the manager(Operator) image") 66 | cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) 67 | _, err = utils.Run(cmd) 68 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 69 | 70 | By("loading the the manager(Operator) image on Kind") 71 | err = utils.LoadImageToKindClusterWithName(projectimage) 72 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 73 | 74 | By("installing CRDs") 75 | cmd = exec.Command("make", "install") 76 | _, err = utils.Run(cmd) 77 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 78 | 79 | By("deploying the controller-manager") 80 | cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage)) 81 | _, err = utils.Run(cmd) 82 | ExpectWithOffset(1, err).NotTo(HaveOccurred()) 83 | 84 | By("validating that the controller-manager pod is running as expected") 85 | verifyControllerUp := func() error { 86 | // Get pod name 87 | 88 | cmd = exec.Command("kubectl", "get", 89 | "pods", "-l", "control-plane=controller-manager", 90 | "-o", "go-template={{ range .items }}"+ 91 | "{{ if not .metadata.deletionTimestamp }}"+ 92 | "{{ .metadata.name }}"+ 93 | "{{ \"\\n\" }}{{ end }}{{ end }}", 94 | "-n", namespace, 95 | ) 96 | 97 | podOutput, err := utils.Run(cmd) 98 | ExpectWithOffset(2, err).NotTo(HaveOccurred()) 99 | podNames := utils.GetNonEmptyLines(string(podOutput)) 100 | if len(podNames) != 1 { 101 | return fmt.Errorf("expect 1 controller pods running, but got %d", len(podNames)) 102 | } 103 | controllerPodName = podNames[0] 104 | ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("controller-manager")) 105 | 106 | // Validate pod status 107 | cmd = exec.Command("kubectl", "get", 108 | "pods", controllerPodName, "-o", "jsonpath={.status.phase}", 109 | "-n", namespace, 110 | ) 111 | status, err := utils.Run(cmd) 112 | ExpectWithOffset(2, err).NotTo(HaveOccurred()) 113 | if string(status) != "Running" { 114 | return fmt.Errorf("controller pod in %s status", status) 115 | } 116 | return nil 117 | } 118 | EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) 119 | 120 | }) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/tasks/integrations/deployment.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/pier-oliviert/konditionner/pkg/konditions" 9 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 10 | "github.com/pier-oliviert/phonebook/api/v1alpha1/integrations" 11 | "github.com/pier-oliviert/phonebook/api/v1alpha1/references" 12 | "github.com/pier-oliviert/phonebook/pkg/providers" 13 | apps "k8s.io/api/apps/v1" 14 | core "k8s.io/api/core/v1" 15 | meta "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/utils/env" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | ) 19 | 20 | type deployment struct { 21 | ctx context.Context 22 | integration *phonebook.DNSIntegration 23 | client.Client 24 | } 25 | 26 | func DeploymentTask(ctx context.Context, c client.Client, integration *phonebook.DNSIntegration) konditions.Task { 27 | t := deployment{ 28 | ctx: ctx, 29 | integration: integration, 30 | Client: c, 31 | } 32 | 33 | return t.Run 34 | } 35 | 36 | func (t deployment) Run(condition konditions.Condition) (konditions.Condition, error) { 37 | d := t.deployment() 38 | if err := t.Create(t.ctx, d); err != nil { 39 | return condition, err 40 | } 41 | 42 | t.integration.Status.Deployment = references.NewReference(d) 43 | condition.Status = konditions.ConditionCreated 44 | condition.Reason = fmt.Sprintf("Deployment Created: %s", d.Name) 45 | 46 | return condition, nil 47 | } 48 | 49 | func (t deployment) deployment() *apps.Deployment { 50 | img := "" 51 | if t.integration.Spec.Provider.Image != nil { 52 | img = *t.integration.Spec.Provider.Image 53 | } else { 54 | img = providers.ProviderImages[t.integration.Spec.Provider.Name] 55 | } 56 | 57 | envs := []core.EnvVar{} 58 | if len(t.integration.Spec.Env) != 0 { 59 | envs = append(envs, t.integration.Spec.Env...) 60 | } 61 | 62 | if t.integration.Spec.SecretRef != nil { 63 | secret := t.integration.Spec.SecretRef 64 | for _, sk := range secret.Keys { 65 | envs = append(envs, core.EnvVar{ 66 | Name: sk.Name, 67 | ValueFrom: &core.EnvVarSource{ 68 | SecretKeyRef: &core.SecretKeySelector{ 69 | LocalObjectReference: secret.Selector(), 70 | Key: sk.Key, 71 | }, 72 | }, 73 | }) 74 | } 75 | } 76 | 77 | envs = append(envs, 78 | core.EnvVar{ 79 | Name: "PB_INTEGRATION", 80 | Value: t.integration.Name, 81 | }, core.EnvVar{ 82 | Name: "PB_ZONES", 83 | Value: strings.Join(t.integration.Spec.Zones, ","), 84 | }, 85 | ) 86 | 87 | container := core.Container{ 88 | Name: "provider", 89 | Env: envs, 90 | Image: img, 91 | ImagePullPolicy: core.PullIfNotPresent, 92 | } 93 | 94 | if len(t.integration.Spec.Provider.Command) != 0 { 95 | container.Command = t.integration.Spec.Provider.Command 96 | } 97 | 98 | if len(t.integration.Spec.Provider.Args) != 0 { 99 | container.Args = t.integration.Spec.Provider.Args 100 | } 101 | 102 | var replicaCount int32 = 1 103 | var controller bool = true 104 | 105 | deployment := &apps.Deployment{ 106 | ObjectMeta: meta.ObjectMeta{ 107 | Name: fmt.Sprintf("provider-%s", t.integration.Name), 108 | Namespace: env.GetString("PB_NAMESPACE", "phonebook-system"), 109 | OwnerReferences: []meta.OwnerReference{{ 110 | APIVersion: t.integration.APIVersion, 111 | Kind: t.integration.Kind, 112 | Name: t.integration.Name, 113 | UID: t.integration.UID, 114 | Controller: &controller, 115 | }}, 116 | Labels: map[string]string{ 117 | integrations.DeploymentLabel: t.integration.Name, 118 | }, 119 | }, 120 | Spec: apps.DeploymentSpec{ 121 | Replicas: &replicaCount, 122 | Selector: &meta.LabelSelector{ 123 | MatchLabels: map[string]string{ 124 | integrations.DeploymentLabel: t.integration.Name, 125 | }, 126 | }, 127 | Template: core.PodTemplateSpec{ 128 | ObjectMeta: meta.ObjectMeta{ 129 | Labels: map[string]string{ 130 | integrations.DeploymentLabel: t.integration.Name, 131 | }, 132 | }, 133 | Spec: core.PodSpec{ 134 | ServiceAccountName: env.GetString("PB_PROVIDER_SERVICE_ACC", "phonebook-providers"), 135 | Containers: []core.Container{container}, 136 | }, 137 | }, 138 | }, 139 | } 140 | 141 | return deployment 142 | } 143 | -------------------------------------------------------------------------------- /docs/integrations/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Integrations' 3 | date: 2024-09-20T10:38:15-04:00 4 | draft: false 5 | cascade: 6 | type: docs 7 | weight: 3 8 | --- 9 | 10 | Phonebook's integration with providers exists through the `DNSIntegration` cluster-scope CRD. Each integration will run its own deployment that manages `DNSRecord` under it's zone authority. To give you a better idea of how this work, imagine a cluster where the 2 following integrations are created. 11 | 12 | ```yaml 13 | apiVersion: se.quencer.io/v1alpha1 14 | kind: DNSIntegration 15 | metadata: 16 | name: cloudflare-demo 17 | spec: 18 | provider: 19 | name: cloudflare 20 | zones: 21 | - mydomain.com 22 | secretRef: 23 | name: cloudflare-secrets 24 | keys: 25 | - key: CF_API_TOKEN 26 | name: CF_API_TOKEN 27 | - key: CF_ZONE_ID 28 | name: CF_ZONE_ID 29 | ``` 30 | 31 | ```yaml 32 | apiVersion: se.quencer.io/v1alpha1 33 | kind: DNSIntegration 34 | metadata: 35 | name: azure-demo 36 | spec: 37 | provider: 38 | name: azure 39 | zones: 40 | - myotherdomain.com 41 | secretRef: 42 | name: azure-secrets 43 | keys: 44 | - name: "AZURE_ZONE_NAME" 45 | key: "zoneName" 46 | - name: "AZURE_RESOURCE_GROUP" 47 | key: "rgName" 48 | - name: "AZURE_SUBSCRIPTION_ID" 49 | key: "subId" 50 | - name: "AZURE_TENANT_ID" 51 | key: "tenantId" 52 | - name: "AZURE_CLIENT_ID" 53 | key: "clientId" 54 | - name: "AZURE_CLIENT_SECRET" 55 | key: "clientSecret" 56 | ``` 57 | 58 | Any DNSRecord created with the zone `mydomain.com` would be handled by the `cloudflare-demo` integration. If you'd create a DNSRecord with `myotherdomain.com` as the zone, Azure will be used. 59 | 60 | ```yaml 61 | # This will create a new `A` record `helloworld.mydomain.com` pointing 62 | # at `127.0.0.1` using `cloudflare-demo` as the integration 63 | apiVersion: se.quencer.io/v1alpha1 64 | kind: DNSRecord 65 | metadata: 66 | name: dnsrecord-sample 67 | namespace: phonebook-system 68 | spec: 69 | zone: mydomain.com 70 | recordType: A 71 | name: helloworld 72 | targets: 73 | - 127.0.0.1 74 | - 127.0.0.2 # If provider supports multi-target 75 | ``` 76 | 77 | ## Split-Horizon DNS 78 | 79 | Alternatively, if you want to do [split-horizon DNS](https://en.wikipedia.org/wiki/Split-horizon_DNS), both integrations would share the same zone. Let's use the same `mydomain.com` and configure both cloudflare and azure to use it. 80 | 81 | ```yaml 82 | apiVersion: se.quencer.io/v1alpha1 83 | kind: DNSIntegration 84 | metadata: 85 | name: cloudflare-demo 86 | spec: 87 | provider: 88 | name: cloudflare 89 | zones: 90 | - mydomain.com 91 | secretRef: 92 | name: cloudflare-secrets 93 | keys: 94 | - key: CF_API_TOKEN 95 | name: CF_API_TOKEN 96 | - key: CF_ZONE_ID 97 | name: CF_ZONE_ID 98 | ``` 99 | 100 | ```yaml 101 | apiVersion: se.quencer.io/v1alpha1 102 | kind: DNSIntegration 103 | metadata: 104 | name: azure-demo 105 | spec: 106 | provider: 107 | name: azure 108 | zones: 109 | - mydomain.com # Same as cloudflare-demo 110 | secretRef: 111 | name: azure-secrets 112 | keys: 113 | - name: "AZURE_ZONE_NAME" 114 | key: "zoneName" 115 | - name: "AZURE_RESOURCE_GROUP" 116 | key: "rgName" 117 | - name: "AZURE_SUBSCRIPTION_ID" 118 | key: "subId" 119 | - name: "AZURE_TENANT_ID" 120 | key: "tenantId" 121 | - name: "AZURE_CLIENT_ID" 122 | key: "clientId" 123 | - name: "AZURE_CLIENT_SECRET" 124 | key: "clientSecret" 125 | ``` 126 | 127 | Now, you'll want to have different values for the same DNS Record. You can do this by using the optional `integration` field in the `DNSRecord`. 128 | 129 | ```yaml 130 | # Use azure for this record 131 | apiVersion: se.quencer.io/v1alpha1 132 | kind: DNSRecord 133 | metadata: 134 | name: hello-azure 135 | namespace: phonebook-system 136 | spec: 137 | zone: mydomain.com 138 | recordType: A 139 | name: helloworld 140 | targets: 141 | - 127.0.0.1 142 | integration: azure-demo 143 | ``` 144 | 145 | ```yaml 146 | # Use cloudflare for this record 147 | apiVersion: se.quencer.io/v1alpha1 148 | kind: DNSRecord 149 | metadata: 150 | name: hello-cloudflare 151 | namespace: phonebook-system 152 | spec: 153 | zone: mydomain.com 154 | recordType: A 155 | name: helloworld 156 | targets: 157 | - 127.0.0.5 158 | integration: cloudflare-demo 159 | ``` 160 | 161 | -------------------------------------------------------------------------------- /test/utils/utils.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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:golint,revive 26 | ) 27 | 28 | const ( 29 | prometheusOperatorVersion = "v0.72.0" 30 | prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + 31 | "releases/download/%s/bundle.yaml" 32 | 33 | certmanagerVersion = "v1.14.4" 34 | certmanagerURLTmpl = "https://github.com/jetstack/cert-manager/releases/download/%s/cert-manager.yaml" 35 | ) 36 | 37 | func warnError(err error) { 38 | _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) 39 | } 40 | 41 | // InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. 42 | func InstallPrometheusOperator() error { 43 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 44 | cmd := exec.Command("kubectl", "create", "-f", url) 45 | _, err := Run(cmd) 46 | return err 47 | } 48 | 49 | // Run executes the provided command within this context 50 | func Run(cmd *exec.Cmd) ([]byte, error) { 51 | dir, _ := GetProjectDir() 52 | cmd.Dir = dir 53 | 54 | if err := os.Chdir(cmd.Dir); err != nil { 55 | _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) 56 | } 57 | 58 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 59 | command := strings.Join(cmd.Args, " ") 60 | _, _ = fmt.Fprintf(GinkgoWriter, "running: %s\n", command) 61 | output, err := cmd.CombinedOutput() 62 | if err != nil { 63 | return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) 64 | } 65 | 66 | return output, nil 67 | } 68 | 69 | // UninstallPrometheusOperator uninstalls the prometheus 70 | func UninstallPrometheusOperator() { 71 | url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) 72 | cmd := exec.Command("kubectl", "delete", "-f", url) 73 | if _, err := Run(cmd); err != nil { 74 | warnError(err) 75 | } 76 | } 77 | 78 | // UninstallCertManager uninstalls the cert manager 79 | func UninstallCertManager() { 80 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 81 | cmd := exec.Command("kubectl", "delete", "-f", url) 82 | if _, err := Run(cmd); err != nil { 83 | warnError(err) 84 | } 85 | } 86 | 87 | // InstallCertManager installs the cert manager bundle. 88 | func InstallCertManager() error { 89 | url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) 90 | cmd := exec.Command("kubectl", "apply", "-f", url) 91 | if _, err := Run(cmd); err != nil { 92 | return err 93 | } 94 | // Wait for cert-manager-webhook to be ready, which can take time if cert-manager 95 | // was re-installed after uninstalling on a cluster. 96 | cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", 97 | "--for", "condition=Available", 98 | "--namespace", "cert-manager", 99 | "--timeout", "5m", 100 | ) 101 | 102 | _, err := Run(cmd) 103 | return err 104 | } 105 | 106 | // LoadImageToKindClusterWithName loads a local docker image to the kind cluster 107 | func LoadImageToKindClusterWithName(name string) error { 108 | cluster := "kind" 109 | if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { 110 | cluster = v 111 | } 112 | kindOptions := []string{"load", "docker-image", name, "--name", cluster} 113 | cmd := exec.Command("kind", kindOptions...) 114 | _, err := Run(cmd) 115 | return err 116 | } 117 | 118 | // GetNonEmptyLines converts given command output string into individual objects 119 | // according to line breakers, and ignores the empty elements in it. 120 | func GetNonEmptyLines(output string) []string { 121 | var res []string 122 | elements := strings.Split(output, "\n") 123 | for _, element := range elements { 124 | if element != "" { 125 | res = append(res, element) 126 | } 127 | } 128 | 129 | return res 130 | } 131 | 132 | // GetProjectDir will return the directory where the project is 133 | func GetProjectDir() (string, error) { 134 | wd, err := os.Getwd() 135 | if err != nil { 136 | return wd, err 137 | } 138 | wd = strings.Replace(wd, "/test/e2e", "", -1) 139 | return wd, nil 140 | } 141 | -------------------------------------------------------------------------------- /internal/solver/solver_test.go: -------------------------------------------------------------------------------- 1 | package solver 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | whapi "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" 9 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 10 | "k8s.io/apimachinery/pkg/types" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 13 | "sigs.k8s.io/controller-runtime/pkg/client/interceptor" 14 | ) 15 | 16 | func TestPresent(t *testing.T) { 17 | var result *phonebook.DNSRecord 18 | client := fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { 19 | 20 | var ok bool 21 | result, ok = obj.(*phonebook.DNSRecord) 22 | if !ok { 23 | t.Error("Expected client.Object to be a DNSRecord") 24 | } 25 | 26 | return nil 27 | }, 28 | }).Build() 29 | 30 | solver := Solver{ 31 | Client: client, 32 | } 33 | 34 | err := solver.Present(&whapi.ChallengeRequest{ 35 | UID: types.UID("testerino"), 36 | Key: "test-1234", 37 | ResolvedFQDN: "my.domain.test", 38 | ResourceNamespace: "phonebook-test", 39 | }) 40 | 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | if len(result.Spec.Targets) != 1 || result.Spec.Targets[0] != "test-1234" { 46 | t.Error("Target doesn't match the challenge request", "Target", result.Spec.Targets) 47 | } 48 | 49 | if result.Namespace != "phonebook-test" { 50 | t.Error("Namespace doesn't match the challenge request", "Namespace", result.Namespace) 51 | } 52 | 53 | if result.Spec.Name != "my.domain.test" { 54 | t.Error("ResolvedFQDN doesn't match the challenge request", "ResolvedFQDN", result.Spec.Name) 55 | } 56 | 57 | if result.Spec.RecordType != "TXT" { 58 | t.Error("RecordType isn't TXT", "RecordType", result.Spec.RecordType) 59 | } 60 | 61 | if val, ok := result.Labels[kChallengeLabel]; !ok || val != kChallengeKey { 62 | t.Error("Label for DNS-01 not properly set", "Labels", result.Labels) 63 | } 64 | } 65 | 66 | func TestCleanUpRecordWithMultipleTargets(t *testing.T) { 67 | records := &phonebook.DNSRecordList{ 68 | Items: []phonebook.DNSRecord{{ 69 | Spec: phonebook.DNSRecordSpec{ 70 | Targets: []string{"one", "two"}, 71 | }, 72 | }}, 73 | } 74 | 75 | client := fake.NewClientBuilder(). 76 | WithInterceptorFuncs(interceptor.Funcs{ 77 | List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { 78 | // Store the value of `records` in `list` 79 | val := reflect.ValueOf(list).Elem() 80 | val.Set(reflect.ValueOf(records).Elem()) 81 | 82 | return nil 83 | }, 84 | Delete: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { 85 | return nil 86 | }, 87 | }).Build() 88 | 89 | solver := Solver{ 90 | Client: client, 91 | } 92 | 93 | err := solver.CleanUp(&whapi.ChallengeRequest{ 94 | UID: types.UID("testerino"), 95 | Key: "test-1234", 96 | ResolvedFQDN: "my.domain.test", 97 | ResourceNamespace: "phonebook-test", 98 | }) 99 | 100 | if err == nil { 101 | t.Error("Expected an error from the CleanUp method due to multiple targets", "List", records) 102 | } 103 | } 104 | 105 | func TestCleanUpRecordValidRecord(t *testing.T) { 106 | deletedCount := 0 107 | var lastDeletedRecord *phonebook.DNSRecord 108 | 109 | records := &phonebook.DNSRecordList{ 110 | Items: []phonebook.DNSRecord{{ 111 | Spec: phonebook.DNSRecordSpec{ 112 | Targets: []string{"test-1234"}, 113 | }, 114 | }}, 115 | } 116 | 117 | client := fake.NewClientBuilder(). 118 | WithInterceptorFuncs(interceptor.Funcs{ 119 | List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { 120 | // Store the value of `records` in `list` 121 | val := reflect.ValueOf(list).Elem() 122 | val.Set(reflect.ValueOf(records).Elem()) 123 | 124 | return nil 125 | }, 126 | Delete: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { 127 | var ok bool 128 | lastDeletedRecord, ok = obj.(*phonebook.DNSRecord) 129 | if !ok { 130 | t.Error("Expected client.Object to be a DNSRecord") 131 | } 132 | 133 | deletedCount = deletedCount + 1 134 | return nil 135 | }, 136 | }).Build() 137 | 138 | solver := Solver{ 139 | Client: client, 140 | } 141 | 142 | err := solver.CleanUp(&whapi.ChallengeRequest{ 143 | UID: types.UID("testerino"), 144 | Key: "test-1234", 145 | ResolvedFQDN: "my.domain.test", 146 | ResourceNamespace: "phonebook-test", 147 | }) 148 | 149 | if deletedCount != 1 { 150 | t.Error("Expected only 1 record to be deleted") 151 | } 152 | 153 | if err != nil { 154 | t.Fatal(err) 155 | } 156 | 157 | // Only comparing specs as other fields are dynamic and irrelevant to this test 158 | if !reflect.DeepEqual(lastDeletedRecord.Spec, records.Items[0].Spec) { 159 | t.Errorf("Unexpected deleted record: %#v", lastDeletedRecord) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /docs/errors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Errors' 3 | date: 2024-09-20T18:29:19-04:00 4 | draft: false 5 | weight: 5 6 | --- 7 | 8 | This is a list of all errors that are coded (PB#_NUM_: ...) in Phonebook. The library encodes all of its errors in this format to give more context to the user when an error comes up. 9 | 10 | # General Error Codes 11 | 12 | |PB#Number|Title|Description| 13 | |:----|-|-| 14 | |PB#0002|**DNS Record not found**|| 15 | |PB#0003|**Provider could not delete the DNS record**|| 16 | |PB#0004|Runtime Initialization failure|Phonebook had a failure in its startup sequence. File an [issue](https://github.com/pier-oliviert/phonebook/issues/new).| 17 | |PB#0005|Deployment is not healthy.|The integration's deployment is not healthy, this can be a temporary issue. Looking at the integration's pod and its log might give you more information.| 18 | |PB#0006|Could not parse the label selector|This is an internal error, if it happens to you, please file an [issue](https://github.com/pier-oliviert/phonebook/issues/new).| 19 | 20 | # DNS-01 Solver Specific Error Codes 21 | 22 | |Number|Title|Description| 23 | |:----|-|-| 24 | |PB-SLV-#0001|A Challenge DNS Record with type TXT had more than one key associated, this is most likely a bug. File an [issue](https://github.com/pier-oliviert/phonebook/issues/new).| 25 | |PB-SLV-#0002|The server accepting challenges could not start due to an error, this is most likely a bug. File an [issue](https://github.com/pier-oliviert/phonebook/issues/new).| 26 | |PB-SLV-#0003|**Could not parse the label selector**|This is an internal error, if it happens to you, please file an [issue](https://github.com/pier-oliviert/phonebook/issues/new).| 27 | 28 | # Provider Specific Error Codes 29 | 30 | |PB-#0100|Provider did not set a condition|Phonebook requires a provider to update the condition's status when the provider create/delete a record.| 31 | 32 | ## Azure 33 | 34 | |Number|Title|Description| 35 | |:----|-|-| 36 | |PB-AZ-#0001|Azure Client ID Not Found|Phonebook failed to find a valid client ID from a secret or env-var for the azure provider| 37 | |PB-AZ-#0002|Azure Client Secret Not Found|Phonebook failed to find a valid client secret from a secret or env-var for the azure provider| 38 | |PB-AZ-#0003|Azure Tenant ID Not Found|Phonebook failed to find a valid tenant ID from a secret or env-var for the azure provider| 39 | |PB-AZ-#0004|Azure Subscription ID Not Found|Phonebook failed to find a valid subscription ID from a secret or env-var for the azure provider| 40 | |PB-AZ-#0005|Azure Zone Name Not Found|Phonebook failed to find a valid zone name from a secret or env-var for the azure provider| 41 | |PB-AZ-#0006|Azure Resource Group Not Found|Phonebook failed to find a valid resource group from a secret or env-var for the azure provider| 42 | |PB-AZ-#0007|Unable to Create Azure Credential|Phonebook was unable to create an Azure credential using the provided information| 43 | |PB-AZ-#0008|Unable to Create Azure DNS Client|Phonebook was unable to create an Azure DNS client using the provided information| 44 | |PB-AZ-#0009|Failed to Create Resource Record Set|Phonebook failed to create a resource record set for Azure DNS| 45 | |PB-AZ-#0010|Failed to Create Azure DNS Record|Phonebook failed to create an Azure DNS record| 46 | |PB-AZ-#0011|Failed to Delete Azure DNS Record|Phonebook failed to delete an Azure DNS record| 47 | |PB-AZ-#0012|CNAME Record Can Only Have One Target|Phonebook attempted to create a CNAME record with multiple targets, which is not allowed| 48 | |PB-AZ-#0013|Invalid MX Record|Phonebook encountered an invalid MX record format| 49 | |PB-AZ-#0014|Invalid SRV Record|Phonebook encountered an invalid SRV record format| 50 | |PB-AZ-#0015|Unsupported Record Type|Phonebook encountered an unsupported DNS record type for Azure DNS| 51 | 52 | ## AWS 53 | 54 | |Number|Title|Description| 55 | |:----|-|-| 56 | |PB-AWS-#0001|Failed to Load AWS Configuration|Phonebook failed to load the AWS configuration| 57 | |PB-AWS-#0002|Zone ID Not Found|Phonebook failed to find a valid AWS Zone ID from a secret or env-var| 58 | |PB-AWS-#0003|Failed to Create DNS Record|Phonebook failed to create a DNS record in AWS Route 53| 59 | |PB-AWS-#0004|Failed to Delete DNS Record|Phonebook failed to delete a DNS record in AWS Route 53| 60 | |PB-AWS-#0005|Unsupported Record Type|Phonebook encountered an unsupported DNS record type for AWS Route 53| 61 | 62 | ## Cloudflare 63 | 64 | |Number|Title|Description| 65 | |:----|-|-| 66 | |PB-CF-#0001|API Key Not Found|Phonebook failed to find a valid Cloudflare API key from a secret or env-var| 67 | |PB-CF-#0002|Zone ID Not Found|Phonebook failed to find a valid Cloudflare Zone ID from a secret or env-var| 68 | |PB-CF-#0003|Unable to Create Cloudflare Client|Phonebook was unable to create a Cloudflare client using the provided information| 69 | |PB-CF-#0004|Multiple Targets Not Supported|Phonebook attempted to create a DNS record with multiple targets, which is not supported by Cloudflare| 70 | 71 | ## deSEC 72 | 73 | |Number|Title|Description| 74 | |:----|-|-| 75 | |PB-DESEC-#0001|deSEC token not found|Phonebook failed to find a valid deSEC API key from a secret or env-var| 76 | |PB-DESEC-#0002|Unable to create record|Phonebook failed to create the DNS record in deSEC| 77 | |PB-DESEC-#0003|Unable to delete record|Phonebook failed to delete the DNS record from deSEC| 78 | -------------------------------------------------------------------------------- /pkg/providers/cloudflare/client.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | client "github.com/cloudflare/cloudflare-go" 9 | "github.com/pier-oliviert/konditionner/pkg/konditions" 10 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 11 | "github.com/pier-oliviert/phonebook/pkg/utils" 12 | ) 13 | 14 | const ( 15 | kCloudflareAPIKeyName = "CF_API_TOKEN" 16 | kCloudflareZoneID = "CF_ZONE_ID" 17 | defaultTTL = int64(60) // Default TTL for DNS records in seconds if not specified 18 | 19 | kCloudflarePropertiesProxied = "proxied" 20 | PropertiesComment = "comment" 21 | PropertiesTags = "tags" 22 | ) 23 | 24 | type cf struct { 25 | integration string 26 | zoneID string 27 | zones []string 28 | 29 | client.API 30 | } 31 | 32 | // Generate a new Cloudflare Provider that can be used to create DNS records. The 33 | // provider requires values to be defined by the user in order to be configured properly. 34 | // 35 | // The CF_API_TOKEN value can either be sourced from an environment variable, or from a file. 36 | // The file needs to be located at `${kProviderConfigPath}/CF_API_TOKEN` 37 | // The file path is preferred as that's easier to work with different providers and Kubernetes secret system. 38 | func NewClient(ctx context.Context) (*cf, error) { 39 | token, err := utils.RetrieveValueFromEnvOrFile(kCloudflareAPIKeyName) 40 | if err != nil { 41 | return nil, fmt.Errorf("PB-CF-#0001: API Key not found -- %w", err) 42 | } 43 | 44 | zoneID, err := utils.RetrieveValueFromEnvOrFile(kCloudflareZoneID) 45 | if err != nil { 46 | return nil, fmt.Errorf("PB-CF-#0002: Zone ID not found -- %w", err) 47 | } 48 | 49 | // Trimming space in case the user included a space when copying the token over. This small 50 | // quality of life fix might just make it easier to work with token (debugging white spaces when trying new tools can be frustrating) 51 | api, err := client.NewWithAPIToken(strings.TrimSpace(token)) 52 | if err != nil { 53 | return nil, fmt.Errorf("PB-CF-#0003: Could not create new Cloudflare Client -- %w", err) 54 | } 55 | 56 | return &cf{ 57 | zoneID: zoneID, 58 | API: *api, 59 | }, nil 60 | } 61 | 62 | func (c *cf) Configure(ctx context.Context, integration string, zones []string) error { 63 | c.integration = integration 64 | c.zones = zones 65 | 66 | return nil 67 | } 68 | 69 | func (c *cf) Zones() []string { 70 | return c.zones 71 | } 72 | 73 | func (c *cf) Create(ctx context.Context, record phonebook.DNSRecord, su phonebook.StagingUpdater) error { 74 | dnsParams := client.CreateDNSRecordParams{ 75 | Type: record.Spec.RecordType, 76 | Name: record.Spec.Name, 77 | Content: record.Spec.Targets[0], 78 | } 79 | 80 | if comment, found := record.Spec.Properties[PropertiesComment]; found { 81 | dnsParams.Comment = comment 82 | } 83 | 84 | if tags, found := record.Spec.Properties[PropertiesTags]; found { 85 | dnsParams.Tags = strings.Split(tags, ";") 86 | } 87 | 88 | // It doesn't seem the cloudflare api library has a way of supporting multiple targets 89 | // I tried to create multiple entries for the same hostname in the CF dashboard and it provides an error, so I'm assuming it's not supported. Shame. 90 | if len(record.Spec.Targets) > 1 { 91 | // Throw an error if the user tries to create multiple targets for the same hostname 92 | return fmt.Errorf("PB-CF-#0004: Cloudflare does not support multiple targets for the same hostname") 93 | } 94 | 95 | // Set TTL 96 | // The cloudflare library only accepts int, so we need to convert the int64 to int 97 | // Shame because it means we have to type convert the default value as well, only for this provider. 98 | if record.Spec.TTL != nil { 99 | dnsParams.TTL = int(*record.Spec.TTL) 100 | } else { 101 | dnsParams.TTL = int(defaultTTL) 102 | } 103 | 104 | if proxied, ok := record.Spec.Properties[kCloudflarePropertiesProxied]; ok { 105 | dnsParams.Proxied = new(bool) 106 | *dnsParams.Proxied = strings.EqualFold(proxied, "true") 107 | } 108 | 109 | response, err := c.CreateDNSRecord(ctx, client.ZoneIdentifier(c.zoneID), dnsParams) 110 | if err != nil { 111 | return fmt.Errorf("PB-CF-#0005: Failed to create DNS record -- %w", err) 112 | } 113 | 114 | su.StageRemoteInfo(phonebook.IntegrationInfo{ 115 | "recordID": response.ID, 116 | }) 117 | su.StageCondition(konditions.ConditionCreated, "Cloudflare record created") 118 | 119 | return nil 120 | } 121 | 122 | func (c *cf) Delete(ctx context.Context, record phonebook.DNSRecord, su phonebook.StagingUpdater) error { 123 | if record.Status.RemoteInfo[c.integration] == nil { 124 | // Nothing to delete if the RemoteID was never added to this resource. It could 125 | // cause an orphan record in Cloudflare, but it might be the better option as the system would 126 | // never be able to recover from a lack of remoteID. 127 | return nil 128 | } 129 | 130 | err := c.DeleteDNSRecord(ctx, client.ZoneIdentifier(c.zoneID), record.Status.RemoteInfo[c.integration]["recordID"]) 131 | if err != nil { 132 | return fmt.Errorf("PB-CF-#0006: Failed to delete DNS record -- %w", err) 133 | } 134 | 135 | su.StageCondition(konditions.ConditionTerminated, "Cloudflare record deleted") 136 | 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /internal/reconcilers/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | 9 | core "k8s.io/api/core/v1" 10 | k8sErrors "k8s.io/apimachinery/pkg/api/errors" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/client-go/tools/record" 13 | ctrl "sigs.k8s.io/controller-runtime" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/log" 16 | 17 | "github.com/pier-oliviert/konditionner/pkg/konditions" 18 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 19 | "github.com/pier-oliviert/phonebook/pkg/providers" 20 | ) 21 | 22 | var ErrProviderDidNotSetCondition = errors.New("PB-#0100: Provider didn't set a condition status upon returning from function") 23 | 24 | // ProviderReconciler handles all incoming reconciliation requests 25 | // for DNSRecord that matches the Integration as defined. It ignores 26 | // any DNSRecord that doesn't fit the requirement set for the 27 | // Provider (ie. zone defined) 28 | type ProviderReconciler struct { 29 | Store *providers.ProviderStore 30 | Integration string 31 | 32 | client.Client 33 | Scheme *runtime.Scheme 34 | record.EventRecorder 35 | } 36 | 37 | // Reconciliation runs for the Provider. 38 | func (r *ProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { 39 | record, err := r.GetRecord(ctx, req) 40 | if k8sErrors.IsNotFound(err) { 41 | return ctrl.Result{}, nil 42 | } 43 | 44 | if err != nil { 45 | return result, err 46 | } 47 | 48 | su := &stageUpdater{ 49 | record: record, 50 | } 51 | 52 | conditionType := konditions.ConditionType(fmt.Sprintf("provider.%s", r.Integration)) 53 | condition := record.Status.Conditions.FindType(conditionType) 54 | if condition == nil || condition.Status == konditions.ConditionError || condition.Status == konditions.ConditionCompleted { 55 | return result, nil 56 | } 57 | 58 | if !slices.Contains(r.Store.Provider().Zones(), record.Spec.Zone) { 59 | // This Provider doesn't have authority over the zone specified by the 60 | // record. 61 | return result, nil 62 | } 63 | 64 | lock := konditions.NewLock(record, r.Client, conditionType) 65 | if lock.Condition().Status == konditions.ConditionError { 66 | return result, nil 67 | } 68 | 69 | switch { 70 | case !record.DeletionTimestamp.IsZero(): 71 | err = lock.Execute(ctx, func(c konditions.Condition) (konditions.Condition, error) { 72 | if err = r.Store.Provider().Delete(ctx, *record.DeepCopy(), su); err != nil { 73 | return c, err 74 | } 75 | 76 | if su.status == nil { 77 | return c, ErrProviderDidNotSetCondition 78 | } 79 | 80 | c.Status = *su.status 81 | 82 | if su.reason != nil { 83 | c.Reason = *su.reason 84 | } 85 | 86 | if su.info != nil { 87 | record.Status.RemoteInfo[r.Integration] = su.info 88 | } 89 | 90 | return c, nil 91 | }) 92 | 93 | case lock.Condition().Status == konditions.ConditionInitialized: 94 | // Execute will update the DNSRecord's Status subresource before 95 | // it returns. Unless there is an error while updating, any field set on the status 96 | // will be persisted by the end of this method. 97 | err = lock.Execute(ctx, func(c konditions.Condition) (konditions.Condition, error) { 98 | if err = r.Store.Provider().Create(ctx, *record.DeepCopy(), su); err != nil { 99 | return c, err 100 | } 101 | if su.status == nil { 102 | return c, ErrProviderDidNotSetCondition 103 | } 104 | c.Status = *su.status 105 | 106 | if su.reason != nil { 107 | c.Reason = *su.reason 108 | } 109 | 110 | if su.info != nil { 111 | if record.Status.RemoteInfo == nil { 112 | record.Status.RemoteInfo = make(map[string]phonebook.IntegrationInfo) 113 | } 114 | 115 | record.Status.RemoteInfo[r.Integration] = su.info 116 | } 117 | 118 | return c, nil 119 | }) 120 | } 121 | 122 | if k8sErrors.IsConflict(err) { 123 | log.FromContext(ctx).Info("Conflict error while updating the DNSRecord, retrying.", "Error", err) 124 | result.Requeue = true 125 | return result, nil 126 | } 127 | 128 | if err != nil { 129 | r.Event( 130 | record, 131 | core.EventTypeWarning, 132 | string(lock.Condition().Status), 133 | err.Error()) 134 | } 135 | 136 | return result, err 137 | } 138 | 139 | func (r *ProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { 140 | return ctrl.NewControllerManagedBy(mgr). 141 | For(&phonebook.DNSRecord{}). 142 | Complete(r) 143 | } 144 | 145 | func (r *ProviderReconciler) GetRecord(ctx context.Context, req ctrl.Request) (*phonebook.DNSRecord, error) { 146 | var record phonebook.DNSRecord 147 | if err := r.Get(ctx, req.NamespacedName, &record); err != nil { 148 | return nil, fmt.Errorf("PB#0002: Couldn't retrieve the resource (%s) -- %w", req.NamespacedName, err) 149 | } 150 | 151 | return &record, nil 152 | } 153 | 154 | type stageUpdater struct { 155 | record *phonebook.DNSRecord 156 | status *konditions.ConditionStatus 157 | reason *string 158 | info phonebook.IntegrationInfo 159 | } 160 | 161 | func (su *stageUpdater) StageCondition(status konditions.ConditionStatus, reason string) { 162 | su.status = &status 163 | su.reason = &reason 164 | } 165 | 166 | func (su *stageUpdater) StageRemoteInfo(info phonebook.IntegrationInfo) { 167 | su.info = info 168 | } 169 | -------------------------------------------------------------------------------- /api/v1alpha1/dnsintegration_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 v1alpha1 18 | 19 | import ( 20 | "github.com/pier-oliviert/konditionner/pkg/konditions" 21 | "github.com/pier-oliviert/phonebook/api/v1alpha1/references" 22 | core "k8s.io/api/core/v1" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | ) 25 | 26 | // A DNSIntegrationSpec represents the bridge between Phonebook's DNSRecord 27 | // and the cloud provider's client that will be in charge of those Records. 28 | // When a DNSIntegration is created, it will create a new deployment using a 29 | // Provider's image. The Deployment will then be in charge of any DNSRecord that 30 | // matches its Provider and Zone, as specified in the DNSRecord. 31 | type DNSIntegrationSpec struct { 32 | // Provider that backs this DNSIntegration, ie. cloudflare, aws, azure, etc. 33 | // This field is used to figure out what Client to initialize and configure. 34 | Provider DNSProviderSpec `json:"provider"` 35 | 36 | // Zones for which this integration has authority over. However, it doesn't mean 37 | // that this provider has exclusivity over the zones. One example would be for 38 | // Split-Horizon DNS (1) where the same Zone can be managed by different providers. 39 | // 40 | // A Provider can own multiple zones. When a DNSRecord is created, it will look for 41 | // a provider if the optional value is set. After, it will look at the DNSRecord's zone 42 | // and attempt to match it against one of the zone listed here. If there's a match, 43 | // the record will be processed by the Provider. 44 | // 45 | // 1. https://en.wikipedia.org/wiki/Split-horizon_DNS 46 | Zones []string `json:"zones"` 47 | 48 | // Env are passed directly to the Provider as Environment Variables for the deployment. This can 49 | // be useful for configurations. It uses native env structure as defined in K8s' docs(1). 50 | // 51 | // It can be useful to source environment variables from config or to set them directly too. 52 | // If you want to source environment variable from secrets, you may use `secretRef` instead as 53 | // it is simpler to use, but that's up to you. 54 | // 55 | // 1. https://kubernetes.io/docs/tasks/inject-data-application/define-environment-variable-container/ 56 | Env []core.EnvVar `json:"env,omitempty"` 57 | 58 | // A reference to a Kubernetes Secret that will be passed to the Provider. Each keys 59 | // defined will be exported as an environment variable to the provider's deployment. 60 | // The SecretRef has precedence over the `Env` field so any keys specified here will override 61 | // values that would otherwise be defined in the `Env` field. 62 | SecretRef *references.SecretRef `json:"secretRef,omitempty"` 63 | } 64 | 65 | type DNSProviderSpec struct { 66 | // Name of the provider as specified in the documentation, ie. cloudflare, aws, azure, etc. 67 | // The name has to be a direct match 68 | Name string `json:"name"` 69 | 70 | // Image name if you want to use a different image name than the default one used 71 | // by Phonebook. If this value isn't set, Phonebook will generate an image name 72 | // based off the Provider's name and Phonebook's default repository. 73 | // It will also always use the `latest` tag 74 | Image *string `json:"image,omitempty"` 75 | 76 | // Command can be spceifici 77 | Command []string `json:"cmd,omitempty"` 78 | 79 | Args []string `json:"args,omitempty"` 80 | } 81 | 82 | // DNSProviderStatus defines the observed state of DNSProvider 83 | type DNSIntegrationStatus struct { 84 | // Set of conditions that the DNSRecord will go through during its 85 | // lifecycle. 86 | Conditions konditions.Conditions `json:"conditions,omitempty"` 87 | 88 | // Reference to the deployment that was created for this 89 | // Integration. 90 | Deployment *references.Reference `json:"deployment,omitempty"` 91 | } 92 | 93 | // +kubebuilder:object:root=true 94 | // +kubebuilder:resource:path=dnsintegrations,scope=Cluster 95 | // +kubebuilder:subresource:status 96 | // DNSProvider is the Schema for the dnsproviders API 97 | type DNSIntegration struct { 98 | metav1.TypeMeta `json:",inline"` 99 | metav1.ObjectMeta `json:"metadata,omitempty"` 100 | 101 | Spec DNSIntegrationSpec `json:"spec,omitempty"` 102 | Status DNSIntegrationStatus `json:"status,omitempty"` 103 | } 104 | 105 | func (d *DNSIntegration) Conditions() *konditions.Conditions { 106 | return &d.Status.Conditions 107 | } 108 | 109 | // +kubebuilder:object:root=true 110 | 111 | // DNSProviderList contains a list of DNSProvider 112 | type DNSIntegrationList struct { 113 | metav1.TypeMeta `json:",inline"` 114 | metav1.ListMeta `json:"metadata,omitempty"` 115 | Items []DNSIntegration `json:"items"` 116 | } 117 | 118 | func init() { 119 | SchemeBuilder.Register(&DNSIntegration{}, &DNSIntegrationList{}) 120 | } 121 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/dnsintegration_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | 23 | apps "k8s.io/api/apps/v1" 24 | core "k8s.io/api/core/v1" 25 | k8sErrors "k8s.io/apimachinery/pkg/api/errors" 26 | "k8s.io/apimachinery/pkg/runtime" 27 | "k8s.io/client-go/tools/record" 28 | ctrl "sigs.k8s.io/controller-runtime" 29 | "sigs.k8s.io/controller-runtime/pkg/builder" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 32 | "sigs.k8s.io/controller-runtime/pkg/event" 33 | "sigs.k8s.io/controller-runtime/pkg/log" 34 | "sigs.k8s.io/controller-runtime/pkg/predicate" 35 | 36 | "github.com/pier-oliviert/konditionner/pkg/konditions" 37 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 38 | "github.com/pier-oliviert/phonebook/api/v1alpha1/integrations" 39 | tasks "github.com/pier-oliviert/phonebook/internal/reconcilers/controller/tasks/integrations" 40 | ) 41 | 42 | // DNSProviderReconciler reconciles a DNSProvider object 43 | type DNSIntegrationReconciler struct { 44 | client.Client 45 | Scheme *runtime.Scheme 46 | record.EventRecorder 47 | } 48 | 49 | // +kubebuilder:rbac:groups=se.quencer.io.se.quencer.io,resources=dnsintegrations,verbs=get;list;watch;create;update;patch;delete 50 | // +kubebuilder:rbac:groups=se.quencer.io.se.quencer.io,resources=dnsintegrations/status,verbs=get;update;patch 51 | // +kubebuilder:rbac:groups=se.quencer.io.se.quencer.io,resources=dnsintegrations/finalizers,verbs=update 52 | func (r *DNSIntegrationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 53 | integration, err := r.GetIntegration(ctx, req) 54 | if k8sErrors.IsNotFound(err) { 55 | return ctrl.Result{}, nil 56 | } 57 | 58 | if err != nil { 59 | return ctrl.Result{}, err 60 | } 61 | 62 | log.FromContext(ctx).Info("Reconciling for", "Integration", integration.Name) 63 | 64 | if !integration.DeletionTimestamp.IsZero() { 65 | condition := integration.Status.Conditions.FindOrInitializeFor(integrations.DeploymentCondition) 66 | if condition.Status == konditions.ConditionTerminated { 67 | if controllerutil.RemoveFinalizer(integration, integrations.DeploymentFinalizer) { 68 | return ctrl.Result{Requeue: true}, r.Update(ctx, integration) 69 | } 70 | } 71 | 72 | condition.Status = konditions.ConditionTerminated 73 | condition.Reason = "Tearing down the integration" 74 | integration.Status.Conditions.SetCondition(condition) 75 | 76 | return ctrl.Result{Requeue: true}, r.Status().Update(ctx, integration) 77 | } 78 | 79 | if controllerutil.AddFinalizer(integration, integrations.DeploymentFinalizer) { 80 | return ctrl.Result{Requeue: true}, r.Update(ctx, integration) 81 | } 82 | 83 | if condition := integration.Conditions().FindOrInitializeFor(integrations.DeploymentCondition); condition.Status == konditions.ConditionInitialized { 84 | lock := konditions.NewLock(integration, r.Client, integrations.DeploymentCondition) 85 | err := lock.Execute(ctx, tasks.DeploymentTask(ctx, r.Client, integration)) 86 | if err != nil { 87 | r.Event(integration, core.EventTypeWarning, string(lock.Condition().Type), err.Error()) 88 | } 89 | return ctrl.Result{}, err 90 | } 91 | 92 | lock := konditions.NewLock(integration, r.Client, integrations.HealthCondition) 93 | err = lock.Execute(ctx, tasks.HealthTask(ctx, r.Client, integration)) 94 | if err != nil { 95 | r.Event(integration, core.EventTypeWarning, string(lock.Condition().Type), err.Error()) 96 | } 97 | 98 | return ctrl.Result{}, nil 99 | } 100 | 101 | func (r *DNSIntegrationReconciler) GetIntegration(ctx context.Context, req ctrl.Request) (*phonebook.DNSIntegration, error) { 102 | var integration phonebook.DNSIntegration 103 | if err := r.Get(ctx, req.NamespacedName, &integration); err != nil { 104 | return nil, fmt.Errorf("PB#0002: Couldn't retrieve the resource (%s) -- %w", req.NamespacedName, err) 105 | } 106 | 107 | return &integration, nil 108 | } 109 | 110 | // SetupWithManager sets up the controller with the Manager. 111 | func (r *DNSIntegrationReconciler) SetupWithManager(mgr ctrl.Manager) error { 112 | p := builder.WithPredicates(predicate.Funcs{ 113 | UpdateFunc: func(e event.TypedUpdateEvent[client.Object]) bool { 114 | oldObj := e.ObjectOld.(*phonebook.DNSIntegration) 115 | newObj := e.ObjectNew.(*phonebook.DNSIntegration) 116 | 117 | // Trigger reconciliation only if the spec.size field has changed 118 | return oldObj.GetGeneration() != newObj.GetGeneration() 119 | }, 120 | CreateFunc: func(e event.CreateEvent) bool { 121 | return true 122 | }, 123 | 124 | // Allow delete events 125 | DeleteFunc: func(e event.DeleteEvent) bool { 126 | return true 127 | }, 128 | 129 | // Allow generic events (e.g., external triggers) 130 | GenericFunc: func(e event.GenericEvent) bool { 131 | return true 132 | }, 133 | }) 134 | 135 | return ctrl.NewControllerManagedBy(mgr). 136 | For(&phonebook.DNSIntegration{}, p). 137 | Owns(&apps.Deployment{}). 138 | Complete(r) 139 | } 140 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/dnsrecord_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | "slices" 23 | "strings" 24 | 25 | k8sErrors "k8s.io/apimachinery/pkg/api/errors" 26 | "k8s.io/apimachinery/pkg/runtime" 27 | "k8s.io/client-go/tools/record" 28 | ctrl "sigs.k8s.io/controller-runtime" 29 | "sigs.k8s.io/controller-runtime/pkg/client" 30 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 31 | "sigs.k8s.io/controller-runtime/pkg/log" 32 | 33 | "github.com/pier-oliviert/konditionner/pkg/konditions" 34 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 35 | ) 36 | 37 | const kDNSRecordFinalizer string = "phonebook.se.quencer.io/finalizer" 38 | 39 | // DNSRecordReconciler reconciles a DNSRecord object 40 | type DNSRecordReconciler struct { 41 | client.Client 42 | Scheme *runtime.Scheme 43 | record.EventRecorder 44 | } 45 | 46 | // DNSRecordReconciler's job is to validate the DNSRecord as well as making sure that 47 | // the finalizer for the record is in its proper state (present or removed) 48 | // 49 | // +kubebuilder:rbac:groups=se.quencer.io,resources=dnsrecords,verbs=get;list;watch;create;update;patch;delete 50 | // +kubebuilder:rbac:groups=se.quencer.io,resources=dnsrecords/status,verbs=get;update;patch 51 | // +kubebuilder:rbac:groups=se.quencer.io,resources=dnsrecords/finalizers,verbs=update 52 | func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 53 | record, err := r.GetRecord(ctx, req) 54 | if k8sErrors.IsNotFound(err) { 55 | return ctrl.Result{}, nil 56 | } 57 | 58 | if err != nil { 59 | return ctrl.Result{}, err 60 | } 61 | 62 | log.FromContext(ctx).Info("Reconciling", "Record", record) 63 | if !record.DeletionTimestamp.IsZero() { 64 | if r.AllProvidersMatchesOneOf(*record.Conditions(), konditions.ConditionError, konditions.ConditionTerminated) { 65 | if controllerutil.RemoveFinalizer(record, kDNSRecordFinalizer) { 66 | return ctrl.Result{Requeue: true}, r.Update(ctx, record) 67 | } 68 | } 69 | return ctrl.Result{}, err 70 | } 71 | 72 | if controllerutil.AddFinalizer(record, kDNSRecordFinalizer) { 73 | log.FromContext(ctx).Info("Finalizer didn't exists") 74 | return ctrl.Result{Requeue: true}, r.Update(ctx, record) 75 | } 76 | 77 | lock := konditions.NewLock(record, r.Client, phonebook.IntegrationCondition) 78 | if lock.Condition().Status == konditions.ConditionCompleted { 79 | return ctrl.Result{}, nil 80 | } 81 | 82 | if lock.Condition().Status == konditions.ConditionInitialized { 83 | return ctrl.Result{}, lock.Execute(ctx, func(c konditions.Condition) (konditions.Condition, error) { 84 | found := 0 85 | var integrations phonebook.DNSIntegrationList 86 | 87 | if err := r.List(ctx, &integrations); err != nil { 88 | return c, err 89 | } 90 | 91 | for _, integration := range integrations.Items { 92 | if record.Spec.Integration != nil { 93 | if *record.Spec.Integration != integration.Name { 94 | continue 95 | } 96 | } 97 | 98 | if slices.Contains(integration.Spec.Zones, record.Spec.Zone) { 99 | found += 1 100 | record.Status.Conditions.SetCondition(konditions.Condition{ 101 | Type: konditions.ConditionType(fmt.Sprintf("provider.%s", integration.Name)), 102 | Status: konditions.ConditionInitialized, 103 | Reason: fmt.Sprintf("Integration has authority over %s", record.Spec.Zone), 104 | }) 105 | } 106 | } 107 | 108 | if found == 0 { 109 | c.Status = konditions.ConditionError 110 | c.Reason = fmt.Sprintf("No Integration matches the zone for this record: %s", record.Spec.Zone) 111 | return c, nil 112 | } 113 | 114 | c.Status = konditions.ConditionCompleted 115 | c.Reason = fmt.Sprintf("Found %d integration that has authority over %s", found, record.Spec.Zone) 116 | return c, nil 117 | }) 118 | } 119 | 120 | return ctrl.Result{}, nil 121 | } 122 | 123 | func (r *DNSRecordReconciler) AllProvidersMatchesOneOf(conditions konditions.Conditions, statuses ...konditions.ConditionStatus) bool { 124 | for _, c := range conditions { 125 | if strings.HasPrefix(string(c.Type), "provider://") { 126 | match := false 127 | for _, s := range statuses { 128 | if c.Status == s { 129 | match = true 130 | } 131 | } 132 | 133 | if !match { 134 | return false 135 | } 136 | } 137 | } 138 | 139 | return true 140 | } 141 | 142 | func (r *DNSRecordReconciler) GetRecord(ctx context.Context, req ctrl.Request) (*phonebook.DNSRecord, error) { 143 | var record phonebook.DNSRecord 144 | if err := r.Get(ctx, req.NamespacedName, &record); err != nil { 145 | return nil, fmt.Errorf("PB#0002: Couldn't retrieve the resource (%s) -- %w", req.NamespacedName, err) 146 | } 147 | 148 | return &record, nil 149 | } 150 | 151 | // SetupWithManager sets up the controller with the Manager. 152 | func (r *DNSRecordReconciler) SetupWithManager(mgr ctrl.Manager) error { 153 | return ctrl.NewControllerManagedBy(mgr). 154 | For(&phonebook.DNSRecord{}). 155 | Complete(r) 156 | } 157 | -------------------------------------------------------------------------------- /internal/reconcilers/controller/dnsrecord_controller_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | 22 | . "github.com/onsi/ginkgo/v2" 23 | . "github.com/onsi/gomega" 24 | "k8s.io/apimachinery/pkg/api/errors" 25 | "k8s.io/apimachinery/pkg/types" 26 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 27 | 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | 30 | "github.com/pier-oliviert/konditionner/pkg/konditions" 31 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 32 | ) 33 | 34 | type TestProvider struct{} 35 | 36 | func (tp TestProvider) Create(context.Context, *phonebook.DNSRecord) error { 37 | return nil 38 | } 39 | 40 | func (tp TestProvider) Delete(context.Context, *phonebook.DNSRecord) error { 41 | return nil 42 | } 43 | 44 | var _ = Describe("DNSRecord Controller", func() { 45 | Context("When reconciling a resource", func() { 46 | const resourceName = "test-resource" 47 | 48 | ctx := context.Background() 49 | 50 | typeNamespacedName := types.NamespacedName{ 51 | Name: resourceName, 52 | Namespace: "default", 53 | } 54 | dnsrecord := &phonebook.DNSRecord{} 55 | 56 | BeforeEach(func() { 57 | By("creating the custom resource for the Kind DNSRecord") 58 | err := k8sClient.Get(ctx, typeNamespacedName, dnsrecord) 59 | if err != nil && errors.IsNotFound(err) { 60 | resource := &phonebook.DNSRecord{ 61 | ObjectMeta: metav1.ObjectMeta{ 62 | Name: resourceName, 63 | Namespace: "default", 64 | }, 65 | Spec: phonebook.DNSRecordSpec{ 66 | Zone: "example.com", 67 | RecordType: "A", 68 | Name: "subdomain", 69 | Targets: []string{"127.0.0.1"}, 70 | }, 71 | } 72 | Expect(k8sClient.Create(ctx, resource)).To(Succeed()) 73 | } 74 | }) 75 | 76 | AfterEach(func() { 77 | // TODO(user): Cleanup logic after each test, like removing the resource instance. 78 | resource := &phonebook.DNSRecord{} 79 | err := k8sClient.Get(ctx, typeNamespacedName, resource) 80 | Expect(err).NotTo(HaveOccurred()) 81 | 82 | By("Cleanup the specific resource instance DNSRecord") 83 | Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) 84 | }) 85 | It("should successfully reconcile the resource", func() { 86 | By("Reconciling the created resource") 87 | controllerReconciler := &DNSRecordReconciler{ 88 | Client: k8sClient, 89 | Scheme: k8sClient.Scheme(), 90 | } 91 | 92 | _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ 93 | NamespacedName: typeNamespacedName, 94 | }) 95 | Expect(err).NotTo(HaveOccurred()) 96 | // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. 97 | // Example: If you expect a certain status condition after reconciliation, verify it here. 98 | }) 99 | }) 100 | 101 | Context("AllProvidersMatchesOneOf", func() { 102 | It("returns false if no conditions are present", func() { 103 | r := &DNSRecordReconciler{} 104 | Expect(r.AllProvidersMatchesOneOf(konditions.Conditions{})).To(Equal(true)) 105 | }) 106 | 107 | It("returns false if no condition status present", func() { 108 | r := &DNSRecordReconciler{} 109 | conditions := konditions.Conditions{{ 110 | Type: konditions.ConditionType("provider://test"), 111 | Status: konditions.ConditionError, 112 | }} 113 | 114 | Expect(r.AllProvidersMatchesOneOf(conditions)).To(Equal(false)) 115 | }) 116 | 117 | It("returns true if no condition for providers present", func() { 118 | r := &DNSRecordReconciler{} 119 | 120 | conditions := konditions.Conditions{{ 121 | Type: konditions.ConditionType("test"), 122 | Status: konditions.ConditionError, 123 | }} 124 | 125 | Expect(r.AllProvidersMatchesOneOf(conditions)).To(Equal(true)) 126 | }) 127 | 128 | It("returns true if all condition for providers matches one of the condition status", func() { 129 | r := &DNSRecordReconciler{} 130 | 131 | conditions := konditions.Conditions{ 132 | { 133 | Type: konditions.ConditionType("not-a-provider"), 134 | Status: konditions.ConditionCreated, 135 | }, 136 | { 137 | Type: konditions.ConditionType("provider://test"), 138 | Status: konditions.ConditionError, 139 | }, 140 | { 141 | Type: konditions.ConditionType("provider://test-1"), 142 | Status: konditions.ConditionTerminated, 143 | }, 144 | { 145 | Type: konditions.ConditionType("provider://test-2"), 146 | Status: konditions.ConditionTerminated, 147 | }, 148 | } 149 | 150 | Expect(r.AllProvidersMatchesOneOf( 151 | conditions, 152 | konditions.ConditionTerminated, 153 | konditions.ConditionError, 154 | konditions.ConditionCompleted), 155 | ).To(Equal(true)) 156 | }) 157 | 158 | It("returns false if one condition for providers matches one of the condition status", func() { 159 | r := &DNSRecordReconciler{} 160 | 161 | conditions := konditions.Conditions{ 162 | { 163 | Type: konditions.ConditionType("provider://test"), 164 | Status: konditions.ConditionLocked, 165 | }, 166 | { 167 | Type: konditions.ConditionType("provider://test-1"), 168 | Status: konditions.ConditionTerminated, 169 | }, 170 | { 171 | Type: konditions.ConditionType("provider://test-2"), 172 | Status: konditions.ConditionTerminated, 173 | }, 174 | } 175 | 176 | Expect(r.AllProvidersMatchesOneOf( 177 | conditions, 178 | konditions.ConditionTerminated, 179 | konditions.ConditionError, 180 | konditions.ConditionCompleted), 181 | ).To(Equal(false)) 182 | }) 183 | }) 184 | }) 185 | -------------------------------------------------------------------------------- /internal/solver/solver.go: -------------------------------------------------------------------------------- 1 | package solver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | whapi "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" 8 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 9 | meta "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/labels" 11 | "k8s.io/client-go/rest" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | const ( 16 | // The Label's key that identify a DNSRecord as part of this solver 17 | kChallengeLabel string = "phonebook.se.quencer.io/solver" 18 | 19 | // The type of challenge the solver has created. Currently, only 20 | // one type of challenge is supported (dns-01) and the way the label is 21 | // created isn't really to be extensible but rather be human-readable so someone 22 | // can look at the resources in their cluster and clearly understands 23 | // what this label is for. 24 | kChallengeKey string = "dns-01-challenge" 25 | ) 26 | 27 | type Solver struct { 28 | group string 29 | name string 30 | 31 | client.Client 32 | } 33 | 34 | // Create a new solver to be used with Phonebook. 35 | // 36 | // The Name of the solver is the name of the interface as defined by RFC2136. 37 | // This is not a user-defined value but rather the name defined in the helm chart that is 38 | // Phonebook specific. 39 | // 40 | // The manager is used to get information about the API Group and to retrieve its fully 41 | // configured client. 42 | // 43 | // The Solver returned is fully configured and ready to go. It won't start 44 | // accepting challenges until `Run()` is called on the solver. 45 | func NewSolver(name string, c client.Client) *Solver { 46 | return &Solver{ 47 | name: name, 48 | group: fmt.Sprintf("phonebook.%s", phonebook.GroupVersion.Group), 49 | Client: c, 50 | } 51 | } 52 | 53 | // Initialize is a no-op to complete the Solver's Interface. 54 | // The client is not needed as one was already provided by the manager 55 | // during the creation of the solver. 56 | func (s *Solver) Initialize(c *rest.Config, stopCh <-chan struct{}) error { 57 | return nil 58 | } 59 | 60 | // Runs the Webhook Server with the solver 61 | // 62 | // This method blocks as it starts the server. 63 | func (s *Solver) Run(ctx context.Context) error { 64 | return Serve(ctx, s) 65 | } 66 | 67 | // Name is the name specified by Phonebook and is required to match 68 | // the APIServer resource created by the helm chart. 69 | func (s *Solver) Name() string { 70 | return s.name 71 | } 72 | 73 | func (s *Solver) Group() string { 74 | return s.group 75 | } 76 | 77 | // Present a challenge to the solver. The Challenge Request comes from 78 | // cert-manager through the webhook integration. Once presented with a challenge, 79 | // a DNS Record needs to be created with the challenge information so cert-manager 80 | // can assert that the domain is owned by user of Phonebook. 81 | // 82 | // The DNSRecord created for a challenge will have a generic "challenge" label 83 | // added to it. This will allow cleanup operations to get a list of all challenges 84 | // and remove those that needs to be cleaned up. Now, in an ideal world, the label 85 | // would have been specific enough so cleanup duties would be able to retrieve only 86 | // the proper challenge records. For example, the label could hold the ResolvedFQDN and 87 | // the cleanup could retrieve DNS Records by ResolvedFQDN. It is, however, impossible 88 | // to implement safely as Labels have a stricter validation set(1) compared to FQDN(2). 89 | // 90 | // The tradeoff is to label challenges and do client-side filtering inside the CleanUp 91 | // method instead. It is reasonable to think that the number of challenges at any given 92 | // time is pretty low, the bandwidth/CPU to filter those records on the client side seems 93 | // acceptable at this time. 94 | // 95 | // 1. https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set 96 | // 2. https://www.ietf.org/rfc/rfc1034.txt 97 | func (s *Solver) Present(ch *whapi.ChallengeRequest) error { 98 | ctx := context.Background() 99 | 100 | ep := &phonebook.DNSRecord{ 101 | ObjectMeta: meta.ObjectMeta{ 102 | Namespace: ch.ResourceNamespace, 103 | GenerateName: "challenge-", 104 | Labels: map[string]string{ 105 | kChallengeLabel: kChallengeKey, 106 | }, 107 | }, 108 | Spec: phonebook.DNSRecordSpec{ 109 | RecordType: "TXT", 110 | Name: ch.ResolvedFQDN, 111 | Targets: []string{ch.Key}, 112 | }, 113 | } 114 | return s.Create(ctx, ep) 115 | } 116 | 117 | // Request to clean up the request after a success/failure. 118 | // At this point, the DNSRecord was possibly created and it needs to be 119 | // deleted so Phonebook can remove it from the Provider. 120 | // 121 | // As described in the Present method, records for challenges have a generic label 122 | // associated with them. The CleanUp method needs to retrieve all dsn records that includes 123 | // this label and do client side filtering to only delete records that have a matching 124 | // challenge Key. 125 | // 126 | // It's possible that the DNSRecord was already deleted, or that more than 1 record with the same key 127 | // exists. All in all, any record that has a matching label & challenge key needs to be deleted 128 | // when this method returns. 129 | // 130 | // Deleting the record will have Phonebook run through the finalizer and delete the record 131 | // on the provider's side. 132 | func (s *Solver) CleanUp(ch *whapi.ChallengeRequest) error { 133 | ctx := context.Background() 134 | 135 | var challenges phonebook.DNSRecordList 136 | 137 | label, err := labels.Parse(fmt.Sprintf("%s=%s", kChallengeLabel, kChallengeKey)) 138 | if err != nil { 139 | return fmt.Errorf("PB-SLV-#0003: failed to parse the label selector -- %w", err) 140 | } 141 | 142 | opts := client.ListOptions{ 143 | LabelSelector: label, 144 | Namespace: ch.ResourceNamespace, 145 | } 146 | 147 | err = s.List(ctx, &challenges, &opts) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | // Delete only the DNSEndpoint that has the same key. It's unlikely there's more than one, but since 153 | // it's a list, let's process all of them. 154 | for _, record := range challenges.Items { 155 | if len(record.Spec.Targets) != 1 { 156 | // Return error 157 | return fmt.Errorf("PB-SLV-0001: Record unexpectedly had more than one target: %v", record.Spec.Targets) 158 | } 159 | 160 | if record.Spec.Targets[0] == ch.Key { 161 | if err := s.Delete(ctx, &record); err != nil { 162 | return err 163 | } 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | -------------------------------------------------------------------------------- /pkg/providers/aws/client.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/aws/aws-sdk-go-v2/service/route53" 9 | "github.com/aws/aws-sdk-go-v2/service/route53/types" 10 | "github.com/pier-oliviert/konditionner/pkg/konditions" 11 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 12 | utils "github.com/pier-oliviert/phonebook/pkg/utils" 13 | "sigs.k8s.io/controller-runtime/pkg/log" 14 | 15 | // Since the Azure provider already uses this package, it 16 | // makes sense to reuse it here too. This is only a convenience 17 | // package for converting values to pointer. Other package exists with 18 | // a similar functionality, but I rather reuse packages to keep the 19 | // depedency tree smaller 20 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 21 | ) 22 | 23 | const ( 24 | kAWSZoneID = "AWS_ZONE_ID" 25 | AliasTarget = "AliasHostedZoneID" 26 | defaultTTL = int64(60) // Default TTL for DNS records in seconds if not specified 27 | ) 28 | 29 | type r53 struct { 30 | integration string 31 | zones []string 32 | zoneID string 33 | *route53.Client 34 | } 35 | 36 | // NewClient doesn't include arguments as all configuration/secret options should be stored 37 | // as environment variable or as secret file mounted by Kubernetes. Since the name of those variables 38 | // and secret files are unique to the provider, it's better for the Client to inspect the system itself 39 | // by using the tools available and return an error if the client cannot be created. 40 | func NewClient(ctx context.Context) (*r53, error) { 41 | logger := log.FromContext(ctx) 42 | cfg, err := config.LoadDefaultConfig(ctx) 43 | if err != nil { 44 | return nil, fmt.Errorf("PB-AWS-#0001: Failed to load AWS configuration -- %w", err) 45 | } 46 | zoneID, err := utils.RetrieveValueFromEnvOrFile(kAWSZoneID) 47 | if err != nil { 48 | return nil, fmt.Errorf("PB-AWS-#0002: Zone ID not found -- %w", err) 49 | } 50 | 51 | logger.Info("[Provider] AWS Configured", "Zone ID", zoneID) 52 | 53 | return &r53{ 54 | zoneID: zoneID, 55 | Client: route53.NewFromConfig(cfg), 56 | }, nil 57 | } 58 | 59 | func (c *r53) Configure(ctx context.Context, integration string, zones []string) error { 60 | c.zones = zones 61 | c.integration = integration 62 | 63 | return nil 64 | } 65 | 66 | func (c *r53) Zones() []string { 67 | return c.zones 68 | } 69 | 70 | func (c *r53) Create(ctx context.Context, record phonebook.DNSRecord, updater phonebook.StagingUpdater) error { 71 | inputs := route53.ChangeResourceRecordSetsInput{ 72 | HostedZoneId: &c.zoneID, 73 | ChangeBatch: &types.ChangeBatch{ 74 | Changes: []types.Change{{ 75 | Action: types.ChangeActionCreate, 76 | ResourceRecordSet: c.resourceRecordSet(ctx, &record), 77 | }}, 78 | }, 79 | } 80 | 81 | _, err := c.ChangeResourceRecordSets(ctx, &inputs) 82 | if err != nil { 83 | return fmt.Errorf("PB-AWS-#0003: Failed to create DNS record -- %w", err) 84 | } 85 | 86 | updater.StageCondition(konditions.ConditionCreated, "Route53 created the record") 87 | return nil 88 | } 89 | 90 | func (c *r53) Delete(ctx context.Context, record phonebook.DNSRecord, updater phonebook.StagingUpdater) error { 91 | inputs := route53.ChangeResourceRecordSetsInput{ 92 | HostedZoneId: &c.zoneID, 93 | ChangeBatch: &types.ChangeBatch{ 94 | Changes: []types.Change{{ 95 | Action: types.ChangeActionDelete, 96 | ResourceRecordSet: c.resourceRecordSet(ctx, &record), 97 | }}, 98 | }, 99 | } 100 | 101 | _, err := c.ChangeResourceRecordSets(ctx, &inputs) 102 | if err != nil { 103 | return fmt.Errorf("PB-AWS-#0004: Failed to delete DNS record -- %w", err) 104 | } 105 | 106 | updater.StageCondition(konditions.ConditionTerminated, "Route53 record deleted") 107 | 108 | return nil 109 | } 110 | 111 | // Convert a DNSRecord to a resourceRecordSet 112 | func (c *r53) resourceRecordSet(ctx context.Context, record *phonebook.DNSRecord) *types.ResourceRecordSet { 113 | fullName := fmt.Sprintf("%s.%s", record.Spec.Name, record.Spec.Zone) 114 | 115 | set := types.ResourceRecordSet{ 116 | Name: &fullName, 117 | Type: types.RRType(record.Spec.RecordType), 118 | } 119 | 120 | // Set TTL 121 | ttl := defaultTTL 122 | if record.Spec.TTL != nil { 123 | ttl = *record.Spec.TTL 124 | } 125 | 126 | set.TTL = &ttl 127 | 128 | if hostedZoneID, ok := record.Spec.Properties[AliasTarget]; ok { 129 | // User specified Alias Hosted Zone ID. As such, Phonebook will 130 | // create a DNS record using AWS' Alias Target function(1). 131 | // 132 | // Alias Target is useful when you want to create a DNS record that points to 133 | // an AWS service. Since AWS can validate that the route doesn't leave their infra, you 134 | // get some benefits like cost reduction, etc. The official documentation will have more 135 | // information about this. 136 | // 137 | // 1. https://docs.aws.amazon.com/Route53/latest/APIReference/API_AliasTarget.html 138 | 139 | set.AliasTarget = &types.AliasTarget{ 140 | DNSName: &record.Spec.Targets[0], 141 | HostedZoneId: &hostedZoneID, 142 | } 143 | // Note: For Alias records, TTL is not used and should be omitted 144 | set.TTL = nil 145 | } else { 146 | // Handle different record types 147 | switch types.RRType(record.Spec.RecordType) { 148 | case types.RRTypeA, types.RRTypeAaaa, types.RRTypeCname: 149 | set.ResourceRecords = make([]types.ResourceRecord, len(record.Spec.Targets)) 150 | for i, target := range record.Spec.Targets { 151 | set.ResourceRecords[i] = types.ResourceRecord{Value: &target} 152 | } 153 | case types.RRTypeTxt: 154 | set.ResourceRecords = make([]types.ResourceRecord, len(record.Spec.Targets)) 155 | for i, target := range record.Spec.Targets { 156 | // AWS TXT Records requires value to be "quoted". For this reason, the Sprintf() method 157 | // is called before wrapping the returned value in a pointer for ResourceRecord. 158 | set.ResourceRecords[i] = types.ResourceRecord{Value: to.Ptr(fmt.Sprintf("\"%s\"", target))} 159 | } 160 | 161 | case types.RRTypeMx: 162 | set.ResourceRecords = make([]types.ResourceRecord, len(record.Spec.Targets)) 163 | for i, target := range record.Spec.Targets { 164 | // Assuming MX records are in the format "priority target" 165 | set.ResourceRecords[i] = types.ResourceRecord{Value: &target} 166 | } 167 | case types.RRTypeSrv: 168 | set.ResourceRecords = make([]types.ResourceRecord, len(record.Spec.Targets)) 169 | for i, target := range record.Spec.Targets { 170 | // Assuming SRV records are in the format "priority weight port target" 171 | set.ResourceRecords[i] = types.ResourceRecord{Value: &target} 172 | } 173 | 174 | default: 175 | // For unsupported types, log an error 176 | log.FromContext(ctx).Error(fmt.Errorf("PB-AWS-#0005: Unsupported record type"), "Record Type", record.Spec.RecordType) 177 | } 178 | } 179 | 180 | return &set 181 | } 182 | -------------------------------------------------------------------------------- /pkg/providers/cloudflare/client_test.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | client "github.com/cloudflare/cloudflare-go" 10 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 11 | ) 12 | 13 | type CloudflareAPI interface { 14 | CreateDNSRecord(ctx context.Context, zoneID string, params client.CreateDNSRecordParams) (client.DNSRecordResponse, error) 15 | DeleteDNSRecord(ctx context.Context, zoneID string, recordID string) error 16 | } 17 | 18 | type mockAPI struct { 19 | CreateDNSRecordFunc func(ctx context.Context, zoneID string, params client.CreateDNSRecordParams) (client.DNSRecordResponse, error) 20 | DeleteDNSRecordFunc func(ctx context.Context, zoneID string, recordID string) error 21 | } 22 | 23 | type cft struct { 24 | integration string 25 | zoneID string 26 | API CloudflareAPI 27 | } 28 | 29 | func (m *mockAPI) CreateDNSRecord(ctx context.Context, zoneID string, params client.CreateDNSRecordParams) (client.DNSRecordResponse, error) { 30 | if m.CreateDNSRecordFunc != nil { 31 | return m.CreateDNSRecordFunc(ctx, zoneID, params) 32 | } 33 | return client.DNSRecordResponse{}, nil 34 | } 35 | 36 | func (m *mockAPI) DeleteDNSRecord(ctx context.Context, zoneID string, recordID string) error { 37 | if m.DeleteDNSRecordFunc != nil { 38 | return m.DeleteDNSRecordFunc(ctx, zoneID, recordID) 39 | } 40 | return nil 41 | } 42 | 43 | func (c *cft) CreateDNSRecord(ctx context.Context, record *phonebook.DNSRecord) error { 44 | params := client.CreateDNSRecordParams{ 45 | Type: record.Spec.RecordType, 46 | Name: record.Spec.Name, 47 | Content: record.Spec.Targets[0], // Assuming the first target is the content 48 | TTL: 1, // You might want to make this configurable 49 | } 50 | 51 | response, err := c.API.CreateDNSRecord(ctx, c.zoneID, params) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | // Set the RemoteID in the record's status 57 | remoteID := response.Result.ID 58 | record.Status.RemoteInfo = map[string]phonebook.IntegrationInfo{ 59 | c.integration: { 60 | "recordID": remoteID, 61 | }, 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // Add Delete method to cft struct (you'll need to implement this) 68 | func (c *cft) Delete(ctx context.Context, record *phonebook.DNSRecord) error { 69 | if record.Status.RemoteInfo[c.integration] == nil { 70 | return nil // Nothing to delete if RemoteID is not set 71 | } 72 | return c.API.DeleteDNSRecord(ctx, c.zoneID, record.Status.RemoteInfo[c.integration]["recordID"]) 73 | } 74 | 75 | // Test for the NewClient function 76 | func TestNewClient(t *testing.T) { 77 | // Test missing API Key 78 | _, err := NewClient(context.TODO()) 79 | if err == nil || !strings.HasPrefix(err.Error(), "PB-CF-#0001: API Key not found --") { 80 | t.Error("Expected error for missing API Key") 81 | } 82 | 83 | // Set API token environment variable 84 | os.Setenv("CF_API_TOKEN", "Some Value") 85 | 86 | // Test missing Zone ID 87 | _, err = NewClient(context.TODO()) 88 | if err == nil || !strings.HasPrefix(err.Error(), "PB-CF-#0002: Zone ID not found --") { 89 | t.Error("Expected error for missing Zone ID") 90 | } 91 | 92 | // Set Zone ID environment variable 93 | os.Setenv("CF_ZONE_ID", "Some Zone ID") 94 | 95 | // Test successful client creation 96 | client, err := NewClient(context.TODO()) 97 | if err != nil { 98 | t.Errorf("Expected successful client creation, got error: %v", err) 99 | } 100 | 101 | if client == nil { 102 | t.Error("Expected a valid client, got nil") 103 | } 104 | } 105 | 106 | // Test for the DNS record creation function 107 | func TestDNSCreation(t *testing.T) { 108 | // Mock environment variables 109 | os.Setenv("CF_API_TOKEN", "Some Value") 110 | os.Setenv("CF_ZONE_ID", "Some Zone ID") 111 | 112 | // Prepare test DNS record 113 | record := phonebook.DNSRecord{ 114 | Spec: phonebook.DNSRecordSpec{ 115 | Zone: "mydomain.com", 116 | Name: "subdomain", 117 | RecordType: "A", 118 | Targets: []string{"127.0.0.1"}, 119 | }, 120 | Status: phonebook.DNSRecordStatus{}, 121 | } 122 | 123 | // Mock the CreateDNSRecord function 124 | mockAPI := &mockAPI{ 125 | CreateDNSRecordFunc: func(ctx context.Context, zoneID string, params client.CreateDNSRecordParams) (client.DNSRecordResponse, error) { 126 | // Return a mock response with a fake record ID 127 | return client.DNSRecordResponse{ 128 | Result: client.DNSRecord{ID: "fake-record-id"}, 129 | }, nil 130 | }, 131 | } 132 | 133 | // Create cf struct with mock API 134 | c := cft{ 135 | integration: "cloudflare-test", 136 | zoneID: "Some Zone ID", 137 | API: mockAPI, // Use the mock API here 138 | } 139 | 140 | // Test DNS record creation 141 | err := c.CreateDNSRecord(context.TODO(), &record) 142 | if err != nil { 143 | t.Errorf("Expected no error, but got: %v", err) 144 | } 145 | 146 | // Check if RemoteID was set correctly 147 | if record.Status.RemoteInfo[c.integration]["recordID"] == "" || record.Status.RemoteInfo[c.integration]["recordID"] != "fake-record-id" { 148 | t.Errorf("Expected RemoteID to be 'fake-record-id', but got: %v", record.Status.RemoteInfo[c.integration]["recordID"]) 149 | } 150 | } 151 | 152 | // Test for the DNS record deletion function 153 | func TestDNSDeletion(t *testing.T) { 154 | // Mock environment variables 155 | os.Setenv("CF_API_TOKEN", "Some Value") 156 | os.Setenv("CF_ZONE_ID", "Some Zone ID") 157 | 158 | // Mock the DeleteDNSRecord function 159 | mockAPI := &mockAPI{ 160 | DeleteDNSRecordFunc: func(ctx context.Context, zoneID string, recordID string) error { 161 | // Check if the correct record ID is being deleted 162 | if recordID != "fake-record-id" { 163 | t.Errorf("Expected record ID 'fake-record-id', got '%s'", recordID) 164 | } 165 | return nil 166 | }, 167 | } 168 | 169 | // Create cft struct with mock API 170 | c := cft{ 171 | integration: "my-test", 172 | zoneID: "Some Zone ID", 173 | API: mockAPI, 174 | } 175 | // 176 | // Prepare test DNS record 177 | recordID := "fake-record-id" 178 | record := phonebook.DNSRecord{ 179 | Spec: phonebook.DNSRecordSpec{ 180 | Zone: "mydomain.com", 181 | Name: "subdomain", 182 | RecordType: "A", 183 | Targets: []string{"127.0.0.1"}, 184 | }, 185 | Status: phonebook.DNSRecordStatus{ 186 | RemoteInfo: map[string]phonebook.IntegrationInfo{ 187 | c.integration: { 188 | "recordID": recordID, 189 | }, 190 | }, 191 | }, 192 | } 193 | 194 | // Test DNS record deletion 195 | err := c.Delete(context.TODO(), &record) 196 | if err != nil { 197 | t.Errorf("Expected no error, but got: %v", err) 198 | } 199 | 200 | // Test deleting a record with no RemoteID 201 | recordWithNoRemoteID := phonebook.DNSRecord{ 202 | Spec: phonebook.DNSRecordSpec{ 203 | Zone: "mydomain.com", 204 | Name: "another-subdomain", 205 | RecordType: "A", 206 | Targets: []string{"192.168.0.1"}, 207 | }, 208 | Status: phonebook.DNSRecordStatus{}, 209 | } 210 | 211 | err = c.Delete(context.TODO(), &recordWithNoRemoteID) 212 | if err != nil { 213 | t.Errorf("Expected no error when deleting record with no RemoteID, but got: %v", err) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release new version 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+* 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | GIT_AUTHOR_EMAIL: ${{vars.AUTHOR_EMAIL}} 12 | GIT_AUTHOR_NAME: $${{vars.AUTHOR_NAME}} 13 | GIT_COMMITTER_EMAIL: ${{vars.AUTHOR_EMAIL}} 14 | GIT_COMMITTER_NAME: $${{vars.AUTHOR_NAME}} 15 | 16 | jobs: 17 | version: 18 | permissions: 19 | contents: read 20 | id-token: write 21 | runs-on: ubuntu-latest 22 | outputs: 23 | tag: ${{ steps.action.outputs.tag }} 24 | version: ${{ steps.action.outputs.version }} 25 | commit_start: ${{ steps.action.outputs.commit_start}} 26 | commit_end: ${{ steps.action.outputs.commit_end}} 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | - name: Generate new version 31 | id: action 32 | uses: pier-oliviert/actions@d55875475d4b2f587e4f7755ccdd8b78b5144495 33 | with: 34 | args: /actions/index.ts create version 35 | repo: ${{ github.repository}} 36 | 37 | # changelog: 38 | # permissions: 39 | # contents: write 40 | # id-token: write 41 | # runs-on: ubuntu-latest 42 | # needs: ["version"] 43 | # outputs: 44 | # changelog: ${{steps.action.outputs.changelog}} 45 | # steps: 46 | # - name: Checkout repository 47 | # uses: actions/checkout@v4 48 | # with: 49 | # fetch-depth: 0 50 | # 51 | # - name: Generate Changelog 52 | # uses: pier-oliviert/actions@d55875475d4b2f587e4f7755ccdd8b78b5144495 53 | # id: action 54 | # env: 55 | # GIT_CLIFF__CHANGELOG__HEADER: "" 56 | # GIT_CLIFF__CHANGELOG__FOOTER: "" 57 | # with: 58 | # args: /actions/index.ts create changelogs 59 | # version: ${{needs.version.outputs.version}} 60 | # commit_start: ${{needs.version.outputs.commit_start}} 61 | # commit_end: ${{needs.version.outputs.commit_end}} 62 | 63 | release: 64 | permissions: 65 | contents: write 66 | id-token: write 67 | runs-on: ubuntu-latest 68 | needs: ["version"] 69 | outputs: 70 | upload_url: ${{steps.action.outputs.upload_url}} 71 | steps: 72 | - name: Checkout repository 73 | uses: actions/checkout@v4 74 | - name: Deploy Helm package 75 | id: action 76 | uses: pier-oliviert/actions@d55875475d4b2f587e4f7755ccdd8b78b5144495 77 | with: 78 | args: /actions/index.ts create release 79 | auth_token: ${{ secrets.GITHUB_TOKEN }} 80 | repo: ${{ github.repository}} 81 | version: ${{ needs.version.outputs.version }} 82 | tag: ${{ needs.version.outputs.tag }} 83 | # changelog: ${{ needs.changelog.outputs.changelog }} 84 | 85 | helm: 86 | permissions: 87 | contents: write 88 | id-token: write 89 | runs-on: ubuntu-latest 90 | needs: ["version", "release"] 91 | steps: 92 | - name: Checkout repository 93 | uses: actions/checkout@v4 94 | with: 95 | ref: main 96 | fetch-depth: 0 97 | - name: Deploy Helm package 98 | uses: pier-oliviert/actions@d55875475d4b2f587e4f7755ccdd8b78b5144495 99 | with: 100 | args: /actions/index.ts create helm 101 | auth_token: ${{ secrets.GITHUB_TOKEN }} 102 | repo: ${{ github.repository }} 103 | upload_url: ${{ needs.release.outputs.upload_url }} 104 | version: ${{ needs.version.outputs.version }} 105 | 106 | build-and-push-image: 107 | runs-on: ubuntu-latest 108 | needs: ["version"] 109 | permissions: 110 | contents: write 111 | packages: write 112 | attestations: write 113 | id-token: write 114 | 115 | steps: 116 | - name: Checkout repository 117 | uses: actions/checkout@v4 118 | 119 | - name: Log in to the Container registry 120 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 121 | with: 122 | registry: ${{ env.REGISTRY }} 123 | username: ${{ github.actor }} 124 | password: ${{ secrets.GITHUB_TOKEN }} 125 | 126 | - name: Controller 127 | id: push 128 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 129 | with: 130 | file: ${{ github.workspace }}/Dockerfile.controller 131 | context: . 132 | target: controller 133 | push: true 134 | build-args: | 135 | PROVIDER_VERSION=${{needs.version.outputs.version}} 136 | tags: | 137 | ${{ env.REGISTRY }}/${{github.repository}}:latest 138 | ${{ env.REGISTRY }}/${{github.repository}}:${{needs.version.outputs.tag}} 139 | 140 | - name: "Providers: AWS" 141 | id: aws 142 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 143 | with: 144 | file: ${{ github.workspace }}/Dockerfile.providers 145 | context: . 146 | target: aws 147 | push: true 148 | tags: | 149 | ${{ env.REGISTRY }}/pier-oliviert/providers-aws:latest 150 | ${{ env.REGISTRY }}/pier-oliviert/providers-aws:${{needs.version.outputs.tag}} 151 | 152 | - name: "Providers: Azure" 153 | id: azure 154 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 155 | with: 156 | file: ${{ github.workspace }}/Dockerfile.providers 157 | context: . 158 | target: azure 159 | push: true 160 | tags: | 161 | ${{ env.REGISTRY }}/pier-oliviert/providers-azure:latest 162 | ${{ env.REGISTRY }}/pier-oliviert/providers-azure:${{needs.version.outputs.tag}} 163 | 164 | - name: "Providers: Cloudflare" 165 | id: cloudflare 166 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 167 | with: 168 | file: ${{ github.workspace }}/Dockerfile.providers 169 | context: . 170 | target: cloudflare 171 | push: true 172 | tags: | 173 | ${{ env.REGISTRY }}/pier-oliviert/providers-cloudflare:latest 174 | ${{ env.REGISTRY }}/pier-oliviert/providers-cloudflare:${{needs.version.outputs.tag}} 175 | 176 | - name: "Providers: deSEC" 177 | id: desec 178 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 179 | with: 180 | file: ${{ github.workspace }}/Dockerfile.providers 181 | context: . 182 | target: desec 183 | push: true 184 | tags: | 185 | ${{ env.REGISTRY }}/pier-oliviert/providers-desec:latest 186 | ${{ env.REGISTRY }}/pier-oliviert/providers-desec:${{needs.version.outputs.tag}} 187 | 188 | - name: "Providers: Gcore" 189 | id: gcore 190 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 191 | with: 192 | file: ${{ github.workspace }}/Dockerfile.providers 193 | context: . 194 | target: gcore 195 | push: true 196 | tags: | 197 | ${{ env.REGISTRY }}/pier-oliviert/providers-gcore:latest 198 | ${{ env.REGISTRY }}/pier-oliviert/providers-gcore:${{needs.version.outputs.tag}} 199 | -------------------------------------------------------------------------------- /pkg/providers/desec/client_test.go: -------------------------------------------------------------------------------- 1 | package desec_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | phonebook "github.com/pier-oliviert/phonebook/api/v1alpha1" 10 | ) 11 | 12 | const ( 13 | kDesecToken = "DESEC_TOKEN" 14 | kDesecMetaRecordIDKey = "desec.io/record_id" 15 | ) 16 | 17 | type DesecAPI interface { 18 | CreateDNSRecord(ctx context.Context, domain, name, recordType, content string, ttl int) error 19 | DeleteDNSRecord(ctx context.Context, domain, name, recordType, content string) error 20 | } 21 | 22 | type mockAPI struct { 23 | CreateDNSRecordFunc func(ctx context.Context, domain, name, recordType, content string, ttl int) error 24 | DeleteDNSRecordFunc func(ctx context.Context, domain, name, recordType, content string) error 25 | } 26 | 27 | type desecClient struct { 28 | integration string 29 | API DesecAPI 30 | } 31 | 32 | func (m *mockAPI) CreateDNSRecord(ctx context.Context, domain, name, recordType, content string, ttl int) error { 33 | if m.CreateDNSRecordFunc != nil { 34 | return m.CreateDNSRecordFunc(ctx, domain, name, recordType, content, ttl) 35 | } 36 | return nil 37 | } 38 | 39 | func (m *mockAPI) DeleteDNSRecord(ctx context.Context, domain, name, recordType, content string) error { 40 | if m.DeleteDNSRecordFunc != nil { 41 | return m.DeleteDNSRecordFunc(ctx, domain, name, recordType, content) 42 | } 43 | return nil 44 | } 45 | 46 | func (c *desecClient) CreateDNSRecord(ctx context.Context, record *phonebook.DNSRecord) error { 47 | domain := record.Spec.Zone 48 | name := record.Spec.Name 49 | recordType := record.Spec.RecordType 50 | content := record.Spec.Targets[0] // Assuming the first target is the content 51 | ttl := 3600 // You might want to make this configurable 52 | 53 | err := c.API.CreateDNSRecord(ctx, domain, name, recordType, content, ttl) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // Set the RemoteID in the record's status 59 | // For deSEC, we don't have a specific ID, so we'll use a combination of name and type 60 | remoteID := fmt.Sprintf("%s-%s", name, recordType) 61 | record.Status.RemoteInfo = map[string]phonebook.IntegrationInfo{ 62 | c.integration: { 63 | "recordID": remoteID, 64 | }, 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (c *desecClient) Delete(ctx context.Context, record *phonebook.DNSRecord) error { 71 | if record.Status.RemoteInfo[c.integration] == nil { 72 | return nil // Nothing to delete if RemoteID is not set 73 | } 74 | 75 | domain := record.Spec.Zone 76 | name := record.Spec.Name 77 | recordType := record.Spec.RecordType 78 | content := record.Spec.Targets[0] // Assuming the first target is the content 79 | 80 | return c.API.DeleteDNSRecord(ctx, domain, name, recordType, content) 81 | } 82 | 83 | func NewClient(ctx context.Context) (*desecClient, error) { 84 | token := os.Getenv(kDesecToken) 85 | if token == "" { 86 | return nil, fmt.Errorf("PB-DESEC-#0001: API Token not found -- Please set the %s environment variable", kDesecToken) 87 | } 88 | 89 | // In a real implementation, you'd create an actual API client here 90 | // For this mock, we'll just return a client with a mock API 91 | return &desecClient{ 92 | API: &mockAPI{}, 93 | }, nil 94 | } 95 | 96 | // Test functions 97 | 98 | func TestNewClient(t *testing.T) { 99 | // Test missing API Token 100 | _, err := NewClient(context.TODO()) 101 | if err == nil || err.Error() != fmt.Sprintf("PB-DESEC-#0001: API Token not found -- Please set the %s environment variable", kDesecToken) { 102 | t.Error("Expected error for missing API Token") 103 | } 104 | 105 | // Set API token environment variable 106 | os.Setenv(kDesecToken, "SomeValue") 107 | defer os.Unsetenv(kDesecToken) 108 | 109 | // Test successful client creation 110 | client, err := NewClient(context.TODO()) 111 | if err != nil { 112 | t.Errorf("Expected successful client creation, got error: %v", err) 113 | } 114 | 115 | if client == nil { 116 | t.Error("Expected a valid client, got nil") 117 | } 118 | } 119 | 120 | func TestDNSCreation(t *testing.T) { 121 | // Set API token environment variable 122 | os.Setenv(kDesecToken, "SomeValue") 123 | defer os.Unsetenv(kDesecToken) 124 | 125 | // Prepare test DNS record 126 | record := phonebook.DNSRecord{ 127 | Spec: phonebook.DNSRecordSpec{ 128 | Zone: "mydomain.com", 129 | Name: "subdomain", 130 | RecordType: "A", 131 | Targets: []string{"127.0.0.1"}, 132 | }, 133 | Status: phonebook.DNSRecordStatus{}, 134 | } 135 | 136 | // Mock the CreateDNSRecord function 137 | mockAPI := &mockAPI{ 138 | CreateDNSRecordFunc: func(ctx context.Context, domain, name, recordType, content string, ttl int) error { 139 | // Perform some checks 140 | if domain != "mydomain.com" || name != "subdomain" || recordType != "A" || content != "127.0.0.1" { 141 | return fmt.Errorf("unexpected values in CreateDNSRecord") 142 | } 143 | return nil 144 | }, 145 | } 146 | 147 | // Create desecClient with mock API 148 | c := desecClient{ 149 | integration: "mytest", 150 | API: mockAPI, 151 | } 152 | 153 | // Test DNS record creation 154 | err := c.CreateDNSRecord(context.TODO(), &record) 155 | if err != nil { 156 | t.Errorf("Expected no error, but got: %v", err) 157 | } 158 | 159 | // Check if RemoteID was set correctly 160 | expectedRemoteID := "subdomain-A" 161 | if record.Status.RemoteInfo[c.integration]["recordID"] == "" || record.Status.RemoteInfo[c.integration]["recordID"] != expectedRemoteID { 162 | t.Errorf("Expected RemoteID to be '%s', but got: %v", expectedRemoteID, record.Status.RemoteInfo[c.integration]["recordID"]) 163 | } 164 | } 165 | 166 | func TestDNSDeletion(t *testing.T) { 167 | // Set API token environment variable 168 | os.Setenv(kDesecToken, "SomeValue") 169 | defer os.Unsetenv(kDesecToken) 170 | 171 | // Mock the DeleteDNSRecord function 172 | mockAPI := &mockAPI{ 173 | DeleteDNSRecordFunc: func(ctx context.Context, domain, name, recordType, content string) error { 174 | // Perform some checks 175 | if domain != "mydomain.com" || name != "subdomain" || recordType != "A" || content != "127.0.0.1" { 176 | return fmt.Errorf("unexpected values in DeleteDNSRecord") 177 | } 178 | return nil 179 | }, 180 | } 181 | 182 | // Create desecClient with mock API 183 | c := desecClient{ 184 | integration: "Another-test", 185 | API: mockAPI, 186 | } 187 | // 188 | // Prepare test DNS record 189 | remoteID := "subdomain-A" 190 | record := phonebook.DNSRecord{ 191 | Spec: phonebook.DNSRecordSpec{ 192 | Zone: "mydomain.com", 193 | Name: "subdomain", 194 | RecordType: "A", 195 | Targets: []string{"127.0.0.1"}, 196 | }, 197 | Status: phonebook.DNSRecordStatus{ 198 | RemoteInfo: map[string]phonebook.IntegrationInfo{ 199 | c.integration: { 200 | "recordID": remoteID, 201 | }, 202 | }, 203 | }, 204 | } 205 | 206 | // Test DNS record deletion 207 | err := c.Delete(context.TODO(), &record) 208 | if err != nil { 209 | t.Errorf("Expected no error, but got: %v", err) 210 | } 211 | 212 | // Test deleting a record with no RemoteID 213 | recordWithNoRemoteID := phonebook.DNSRecord{ 214 | Spec: phonebook.DNSRecordSpec{ 215 | Zone: "mydomain.com", 216 | Name: "another-subdomain", 217 | RecordType: "A", 218 | Targets: []string{"192.168.0.1"}, 219 | }, 220 | Status: phonebook.DNSRecordStatus{}, 221 | } 222 | 223 | err = c.Delete(context.TODO(), &recordWithNoRemoteID) 224 | if err != nil { 225 | t.Errorf("Expected no error when deleting record with no RemoteID, but got: %v", err) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pier-oliviert/phonebook 2 | 3 | go 1.22.5 4 | 5 | toolchain go1.23.0 6 | 7 | require ( 8 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 9 | github.com/G-Core/gcore-dns-sdk-go v0.2.9 10 | github.com/aws/aws-sdk-go-v2/config v1.27.36 11 | github.com/aws/aws-sdk-go-v2/service/route53 v1.44.0 12 | github.com/cert-manager/cert-manager v1.16.0-beta.0 13 | github.com/cloudflare/cloudflare-go v0.104.0 14 | github.com/nrdcg/desec v0.8.0 15 | github.com/onsi/ginkgo/v2 v2.19.0 16 | github.com/onsi/gomega v1.33.1 17 | github.com/pier-oliviert/konditionner v0.2.5 18 | github.com/stretchr/testify v1.9.0 19 | k8s.io/api v0.31.1 20 | k8s.io/apimachinery v0.31.1 21 | k8s.io/apiserver v0.31.1 22 | k8s.io/client-go v0.31.1 23 | k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 24 | sigs.k8s.io/controller-runtime v0.19.0 25 | ) 26 | 27 | require ( 28 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect 29 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect 30 | github.com/NYTimes/gziphandler v1.1.1 // indirect 31 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 32 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 33 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 34 | github.com/kylelemons/godebug v1.1.0 // indirect 35 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 36 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 37 | github.com/stretchr/objx v0.5.2 // indirect 38 | golang.org/x/crypto v0.27.0 // indirect 39 | ) 40 | 41 | require ( 42 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 43 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 44 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 45 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 46 | github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect 47 | github.com/aws/aws-sdk-go-v2/credentials v1.17.34 // indirect 48 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect 49 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect 50 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect 51 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 52 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect 53 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect 54 | github.com/aws/aws-sdk-go-v2/service/sso v1.23.0 // indirect 55 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.0 // indirect 56 | github.com/aws/aws-sdk-go-v2/service/sts v1.31.0 // indirect 57 | github.com/aws/smithy-go v1.21.0 // indirect 58 | github.com/beorn7/perks v1.0.1 // indirect 59 | github.com/blang/semver/v4 v4.0.0 // indirect 60 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 61 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 62 | github.com/coreos/go-semver v0.3.1 // indirect 63 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 64 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 65 | github.com/emicklei/go-restful/v3 v3.12.1 // indirect 66 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 67 | github.com/felixge/httpsnoop v1.0.4 // indirect 68 | github.com/fsnotify/fsnotify v1.7.0 // indirect 69 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 70 | github.com/go-logr/logr v1.4.2 // indirect 71 | github.com/go-logr/stdr v1.2.2 // indirect 72 | github.com/go-logr/zapr v1.3.0 // indirect 73 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 74 | github.com/go-openapi/jsonreference v0.21.0 // indirect 75 | github.com/go-openapi/swag v0.23.0 // indirect 76 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 77 | github.com/goccy/go-json v0.10.3 // indirect 78 | github.com/gogo/protobuf v1.3.2 // indirect 79 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 80 | github.com/golang/protobuf v1.5.4 // indirect 81 | github.com/google/cel-go v0.20.1 // indirect 82 | github.com/google/gnostic-models v0.6.8 // indirect 83 | github.com/google/go-cmp v0.6.0 // indirect 84 | github.com/google/go-querystring v1.1.0 // indirect 85 | github.com/google/gofuzz v1.2.0 // indirect 86 | github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect 87 | github.com/google/uuid v1.6.0 // indirect 88 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect 89 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect 90 | github.com/imdario/mergo v0.3.16 // indirect 91 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 92 | github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect 93 | github.com/josharian/intern v1.0.0 // indirect 94 | github.com/json-iterator/go v1.1.12 // indirect 95 | github.com/klauspost/compress v1.17.9 // indirect 96 | github.com/mailru/easyjson v0.7.7 // indirect 97 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 98 | github.com/modern-go/reflect2 v1.0.2 // indirect 99 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 100 | github.com/pkg/errors v0.9.1 // indirect 101 | github.com/prometheus/client_golang v1.20.4 // indirect 102 | github.com/prometheus/client_model v0.6.1 // indirect 103 | github.com/prometheus/common v0.55.0 // indirect 104 | github.com/prometheus/procfs v0.15.1 // indirect 105 | github.com/spf13/cobra v1.8.1 // indirect 106 | github.com/spf13/pflag v1.0.5 // indirect 107 | github.com/stoewer/go-strcase v1.3.0 // indirect 108 | github.com/x448/float16 v0.8.4 // indirect 109 | go.etcd.io/etcd/api/v3 v3.5.14 // indirect 110 | go.etcd.io/etcd/client/pkg/v3 v3.5.14 // indirect 111 | go.etcd.io/etcd/client/v3 v3.5.14 // indirect 112 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect 113 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 114 | go.opentelemetry.io/otel v1.30.0 // indirect 115 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect 116 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect 117 | go.opentelemetry.io/otel/metric v1.30.0 // indirect 118 | go.opentelemetry.io/otel/sdk v1.30.0 // indirect 119 | go.opentelemetry.io/otel/trace v1.30.0 // indirect 120 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 121 | go.uber.org/multierr v1.11.0 // indirect 122 | go.uber.org/zap v1.27.0 // indirect 123 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 124 | golang.org/x/net v0.29.0 // indirect 125 | golang.org/x/oauth2 v0.23.0 // indirect 126 | golang.org/x/sync v0.8.0 127 | golang.org/x/sys v0.25.0 // indirect 128 | golang.org/x/term v0.24.0 // indirect 129 | golang.org/x/text v0.18.0 // indirect 130 | golang.org/x/time v0.6.0 // indirect 131 | golang.org/x/tools v0.24.0 // indirect 132 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 133 | google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect 134 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 135 | google.golang.org/grpc v1.66.2 // indirect 136 | google.golang.org/protobuf v1.34.2 // indirect 137 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 138 | gopkg.in/inf.v0 v0.9.1 // indirect 139 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 140 | gopkg.in/yaml.v2 v2.4.0 // indirect 141 | gopkg.in/yaml.v3 v3.0.1 // indirect 142 | k8s.io/apiextensions-apiserver v0.31.1 // indirect 143 | k8s.io/component-base v0.31.1 // indirect 144 | k8s.io/klog/v2 v2.130.1 // indirect 145 | k8s.io/kms v0.31.1 // indirect 146 | k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 // indirect 147 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect 148 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 149 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 150 | sigs.k8s.io/yaml v1.4.0 // indirect 151 | ) 152 | -------------------------------------------------------------------------------- /docs/integrations/aws/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'AWS' 3 | date: 2024-09-20T10:38:15-04:00 4 | draft: false 5 | weight: 1 6 | --- 7 | 8 | ## Zone ID 9 | 10 | The AWS Zone ID needs to be specified in your `values.yaml`. The Zone ID needs to point to the domain you want to manage. Currently, only 1 domain can be managed by Phonebook. 11 | 12 | ## Authentication 13 | You have two options when you configure your AWS provider. Depending on your setup, one might be more suited to your need than the other. 14 | 15 | - IAM Role bound to a service account 16 | - User supplied credentials 17 | 18 | ### IAM Role bound to a service account 19 | 20 | This option is the recommended one if your Kubernetes cluster supports it. Most of the configuration happens on the AWS control panel and the changes to your helm chart is minimal. Moreover, you won't have to store any credentials at all on K8s as it will all be taken care of by EKS. 21 | 22 | {{< callout type="warning" >}} 23 | You should already have an EKS cluster running in your account, with a running node group. The EKS Cluster should also be configured to use **EKS API** as an authentication mode. 24 | {{< /callout >}} 25 | 26 | First, you'll need to add an annotation to the serviceAccount that Phonebook uses to run the Provider's deployment. 27 | 28 | ```yaml 29 | serviceAccount: 30 | annotations: 31 | eks.amazonaws.com/role-arn: arn:aws:iam::1111111111:role/Phonebook-ServiceAccount 32 | ``` 33 | 34 | Then, you can create a DNSIntegration that is configured with the `AWS_ZONE_ID`. 35 | ```yaml 36 | apiVersion: se.quencer.io/v1alpha1 37 | kind: DNSIntegration 38 | metadata: 39 | name: aws 40 | spec: 41 | provider: 42 | name: aws 43 | zones: 44 | - mydomain.com 45 | env: 46 | - name: AWS_ZONE_ID 47 | value: Z1111111111111 48 | ``` 49 | 50 | 51 | #### Configure OIDC (OpenID Connect) 52 | 53 | This section will guide you through configuring your [EKS](https://aws.amazon.com/eks/) cluster to run with Phonebook. It will focus on configuring Phonebook's [`serviceAccount`](https://kubernetes.io/docs/concepts/security/service-accounts/) to allow its controller to make changes to [Route53](https://aws.amazon.com/route53/) on your behalf. 54 | 55 | Once the ServiceAccount is fully configure, Phonebook should be able to make changes to your DNS records by automatically authenticating to AWS using the right permissions as set here; No access token/secret token will be required to be set by you. 56 | 57 | 58 | EKS comes with a few defaults, but OIDC is not fully configured out of the box. Documentation is available on [AWS](https://docs.aws.amazon.com/eks/latest/userguide/enable-iam-roles-for-service-accounts.html) that shows how to connect your EKS cluster to an Identity Provider in your IAM console. 59 | 60 | Once your Identity Provider is configured, you'll be ready to create a role to use it. Keep your OIDC Provider URL close by, you'll need it in the following section 61 | 62 | > ![EKS cluster detail page](./cluster-page.png) 63 | 64 | #### Create a role for your Service Account 65 | 66 | OIDC is the bridge that can connect IAM to your EKS cluster. To make it possible for Phonebook to make changes to Route53, you'll need to create a role that you'll use as annotations with your Service Account. 67 | 68 | {{< callout type="info" >}} 69 | This section will use a fake OIDC Provider URL based on the screenshot above: 70 | 71 | - OIDC Provider URL: `https://oidc.eks.us-east-2.amazonaws.com/id/F1A5247B9AAAAAAAAAAAAA06364EC072201D` 72 | - OIDC ID: `F1A5247B9AAAAAAAAAAAAA06364EC072201D` 73 | 74 | You'll need to replace those values with the ones you have. 75 | {{< /callout >}} 76 | 77 | First, create a new Role. For Trusted Entity, select **Custom trust policy**. In the textbox that should have appeared, replace the content with this template: 78 | 79 | ```json 80 | { 81 | "Version": "2012-10-17", 82 | "Statement": [ 83 | { 84 | "Effect": "Allow", 85 | "Principal": { 86 | "Federated": "arn:aws:iam::${YOUR_ACCOUNT_ID}:oidc-provider/oidc.eks.${YOUR_REGION}.amazonaws.com/id/${OIDC_ID}" 87 | }, 88 | "Action": "sts:AssumeRoleWithWebIdentity", 89 | "Condition": { 90 | "StringEquals": { 91 | "oidc.eks.us-east-2.amazonaws.com/id/${OIDC_ID}:aud": "sts.amazonaws.com", 92 | "oidc.eks.us-east-2.amazonaws.com/id/${OIDC_ID}:sub": "system:serviceaccount:${PHONEBOOK_NAMESPACE}:phonebook-providers" 93 | } 94 | } 95 | } 96 | ] 97 | } 98 | ``` 99 | 100 | |${Variable}|Description| 101 | |--|--| 102 | |YOUR_ACCOUNT_ID|Your AWS Account ID. This is usually a number with 12 digits.| 103 | |YOUR_REGION|The AWS Region for your EKS Cluster. It is part of the OIDC Provider URL. In the example above, the URL is `https://oidc.eks.us-east-2.amazonaws.com/...` which means the region, in this example, is `us-east-2`| 104 | |OIDC_ID|In the example above, the OIDC_ID is `F1A5247B9AAAAAAAAAAAAA06364EC072201D`| 105 | |PHONEBOOK_NAMESPACE|The namespace, in Kubernetes, that phonebook runs in. By default, this value is `phonebook-system`. If you changed it, you'll need to use the same value here too.| 106 | 107 | 108 | #### Define a policy for the new role 109 | 110 | A policy, in AWS, represents what service does the role has access to. Phonebook only needs access to a few of Route53's action. Most likely, you'll need to create a new custom policy for your Role as part of the Role wizard. You can name it whatever you want, it'll only be used by the Role here and won't need to be referenced anywhere. 111 | 112 | ```json 113 | { 114 | "Version": "2012-10-17", 115 | "Statement": [ 116 | { 117 | "Effect": "Allow", 118 | "Action": [ 119 | "route53:ChangeResourceRecordSets" 120 | ], 121 | "Resource": [ 122 | "arn:aws:route53:::hostedzone/*" 123 | ] 124 | }, 125 | { 126 | "Effect": "Allow", 127 | "Action": [ 128 | "route53:ListHostedZones", 129 | "route53:ListResourceRecordSets", 130 | "route53:ListTagsForResource" 131 | ], 132 | "Resource": [ 133 | "*" 134 | ] 135 | } 136 | ] 137 | } 138 | ``` 139 | 140 | #### Add the Role as annotations to Phonebook's service account 141 | 142 | The last piece of the puzzle is to add an annotation to Phonebook's Service account so EKS can elevate this account. In the Role's detail page, you should have the **ARN** for that role. It should look something like this: `arn:aws:iam::1111111111:role/Phonebook-ServiceAccount` 143 | 144 | Modify your `values.yaml` to include the Role ARN as an annotations. 145 | 146 | ```yaml 147 | serviceAccount: 148 | annotations: 149 | eks.amazonaws.com/role-arn: arn:aws:iam::1111111111:role/Phonebook-ServiceAccount 150 | ``` 151 | 152 | ### User supplied credentials 153 | 154 | Although simpler at face value, supplied credentials requires you to manage rotation and make sure that secrets are present in the cluster before using them. Since Phonebook uses the official Go SDK for AWS, you can refer to AWS's [official documentation](https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/) if you want to know more. 155 | 156 | ```sh 157 | kubectl create secrets generic aws-secret \ 158 | --namespace phonebook-system \ 159 | --from-literal=accessKeyID=${ACCESS_KEY} \ 160 | --from-literal=secretAccessKey=${SECRET_KEY} \ 161 | --from-literal=sessionToken=${SESSION_TOKEN} \ 162 | ``` 163 | 164 | Once created, you can create the DNSIntegration that will configure a provider with the secrets you generated. 165 | 166 | ```yaml 167 | apiVersion: se.quencer.io/v1alpha1 168 | kind: DNSIntegration 169 | metadata: 170 | name: aws 171 | spec: 172 | provider: 173 | name: aws 174 | zones: 175 | - mydomain.com 176 | secretRef: 177 | name: aws-secret 178 | keys: 179 | - name: "AWS_ACCESS_KEY_ID" 180 | key: "accessKeyID" 181 | - name: "AWS_SECRET_ACCESS_KEY" 182 | key: "secretAccessKey" 183 | - name: "AWS_SESSION_TOKEN" 184 | key: "sessionToken" 185 | ``` 186 | -------------------------------------------------------------------------------- /api/v1alpha1/dnsrecord_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 v1alpha1 18 | 19 | import ( 20 | "github.com/pier-oliviert/konditionner/pkg/konditions" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | const ( 25 | // The main condition to talk to the provider. Each provider have finer 26 | // states that will be reflected as status for this condition. 27 | ProviderCondition konditions.ConditionType = "Provider" 28 | 29 | IntegrationCondition konditions.ConditionType = "Integration" 30 | ) 31 | 32 | // DNSRecordSpec defines the desired state of DNSRecord and represents 33 | // a single DNS Record. It is expected that each DNS Record won't conflict with each other 34 | // and it's the user's job to make sure that each record have a unique spec. 35 | type DNSRecordSpec struct { 36 | // Zone is the the DNS Zone that you want to create a record for. 37 | // If you want to create a CNAME called foo.mydomain.com, 38 | // "mydomain.com" would be your zone. 39 | // 40 | // The Zone needs to find a match in one of the DNSProvider configured in your 41 | // cluster. Unless the optional `Provider` field is set, Phonebook will look 42 | // at all the providers configured to try to find a match for the zone. 43 | // 44 | // If no provider matches the zone, the record won't be created. 45 | Zone string `json:"zone"` 46 | 47 | // RecordType represent the type for the Record you want to create. 48 | // Can be A, AAAA, CNAME, TXT, etc. 49 | RecordType string `json:"recordType"` 50 | 51 | // Name of the record represents the subdomain in the CNAME example used for zone. 52 | // In that example, the `Name` would be `foo` 53 | Name string `json:"name"` 54 | 55 | // Targets represents where the record should point to. Depending on the record type, 56 | // it can be an IP address or some text value. 57 | // The reason why targets is plural is because some provider support multiple values for 58 | // a given record types. For most cases, it's expected to only have 1 value. 59 | Targets []string `json:"targets"` 60 | 61 | // Provider specific configuration settings that can be used 62 | // to configure a DNS Record in accordance to the provider used. 63 | // Each provider provides its own set of custom fields. 64 | Properties map[string]string `json:"properties,omitempty"` 65 | 66 | // TTL is the Time To Live for the record. It represents the time 67 | // in seconds that the record is cached by resolvers. 68 | // If not set, the provider will use its default value (60 seconds). 69 | TTL *int64 `json:"ttl,omitempty"` 70 | 71 | // Optional field to be more specific about which Provider you want to use for 72 | // this record. This field is useful if you have more than one Provider serving 73 | // the same Zone (ie. Split-Horizon DNS). 74 | // 75 | // In most cases, this field isn't necessary as the Zone field should be enough 76 | // to let Phonebook find the proper Provider. This field only gives a hint to Phonebook 77 | // and the Zones has to match as well. 78 | Integration *string `json:"integration,omitempty"` 79 | } 80 | 81 | // Optional field that a provider can use to keep track of remote data it might need in the future, eg. Remote ID for deleting the 82 | // record. Values can only be string. 83 | type IntegrationInfo map[string]string 84 | 85 | // StagingUpdater is an interface used by providers to safely update DNSRecord's status. Since 86 | // DNSRecord can interact with multiple DNSIntegrations, the DNSRecord's status needs to be scoped for 87 | // each DNSIntegration so they can all keep the DNSRecord status updated without conflicting with each other. 88 | // 89 | // StagingUpdater is a proxy that will scope all changes to the specific condition/IntegrationInfo. It's rather simple, 90 | // each DNSIntegration that has authority over the zone will have its own Condition in the DSNRecord's Conditions as well 91 | // its own entry in the IntegrationInfo map. Both of those will have the name of the DNSIntegration as the unique key, 92 | // which means that even if there's more than one integration for a given provider (aws, cloudflare, etc.), the uniqueness 93 | // of the key is still valid. 94 | // 95 | // As the name of the methods and interface suggest, these operations are only staging. As a result, they aren't persisted 96 | // when those method returns. In fact, multiple calls overwrite the previously set values during the **same reconciliation loop**. 97 | // 98 | // When Create/Destroy returns, the server's reconciliation loop will update the Condition and the IntegrationInfo. 99 | // 100 | // It is important to note that in case of an error returning from Create/Destroy, the error will take precedence over 101 | // the staged condition. The Status will be set to Error and the reason will be set to the error message. 102 | // +kubebuilder:object:generate=false 103 | type StagingUpdater interface { 104 | // StageCondition lets an integration update the condition that is attached to the 105 | // DNSIntegration. 106 | // For instance, if the DNSIntegration was created with the name `my-test-123` and the provider 107 | // is AWS, calling 108 | // StageCondition(konditions.ConditionCreated, "Resource created") 109 | // 110 | // would mean that the condition with the condition type `my-test-123` will 111 | // have the status and reason set at the end of the reconciliation loop. 112 | StageCondition(status konditions.ConditionStatus, reason string) 113 | 114 | // StagingRemoteInfo lets the provider store provider-related information in 115 | // the DNSRecord's RemoteInfo field. This is an optional field. 116 | StageRemoteInfo(IntegrationInfo) 117 | } 118 | 119 | // DNSRecordStatus defines the observed state of DNSRecord 120 | type DNSRecordStatus struct { 121 | // Set of conditions that the DNSRecord will go through during its 122 | // lifecycle. 123 | Conditions konditions.Conditions `json:"conditions,omitempty"` 124 | 125 | // RemoteInfo is a field that can be used by DNSIntegration's provider to 126 | // store information as the Record is created. Each integration has its own map it can 127 | // populate with arbitrary data. Each entries in the root RemoteInfo refers to the name of 128 | // the integration that stored the intormation. For instance, if you have a DNSRecord that 129 | // is shared between 2 integrations named `cloudflare-dev` and `aws-prod`, RemoteInfo would 130 | // look like this: 131 | // map[string]map[string]string{ 132 | // "cloudflare-dev": map[string]string{ 133 | // // cloudflare related information about the record 134 | // }, 135 | // "aws-prod": map[string]string{ 136 | // // aws related information about the record 137 | // } 138 | // } 139 | // 140 | // A DNSIntegration can have multiple entries stored in this field and it's up the integration 141 | // to make sure those fields are not stale. 142 | RemoteInfo map[string]IntegrationInfo `json:"remoteInfo,omitempty"` 143 | } 144 | 145 | // +kubebuilder:object:root=true 146 | // +kubebuilder:subresource:status 147 | 148 | // DNSRecord is the Schema for the dnsrecords API 149 | type DNSRecord struct { 150 | metav1.TypeMeta `json:",inline"` 151 | metav1.ObjectMeta `json:"metadata,omitempty"` 152 | 153 | Spec DNSRecordSpec `json:"spec,omitempty"` 154 | Status DNSRecordStatus `json:"status,omitempty"` 155 | } 156 | 157 | // This helper method is added to DNSRecord to make it match the 158 | // konditions.ConditionalObject interface to use the Lock mechanism 159 | // with konditionner. 160 | func (d *DNSRecord) Conditions() *konditions.Conditions { 161 | return &d.Status.Conditions 162 | } 163 | 164 | // +kubebuilder:object:root=true 165 | 166 | // DNSRecordList contains a list of DNSRecord 167 | type DNSRecordList struct { 168 | metav1.TypeMeta `json:",inline"` 169 | metav1.ListMeta `json:"metadata,omitempty"` 170 | Items []DNSRecord `json:"items"` 171 | } 172 | 173 | func init() { 174 | SchemeBuilder.Register(&DNSRecord{}, &DNSRecordList{}) 175 | } 176 | --------------------------------------------------------------------------------