diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..467f51d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +*.out +*.test +.env diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..c62cf66 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,10 @@ +when: + - event: pull_request + +steps: + - name: docker-build-operator + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/bind-operator + dockerfile: Dockerfile.operator + dry_run: true diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..c40f724 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,17 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker-operator + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/bind-operator + dockerfile: Dockerfile.operator + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..2dd88b8 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,9 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: golang:1.25 + commands: + - test -z "$(gofmt -l .)" + - go vet ./... diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..75fc514 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,8 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race -count=1 ./api/... ./internal/... diff --git a/Dockerfile.operator b/Dockerfile.operator new file mode 100644 index 0000000..34e7306 --- /dev/null +++ b/Dockerfile.operator @@ -0,0 +1,18 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o bind-operator ./cmd/operator + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/bind-operator /usr/local/bin/bind-operator + +ENTRYPOINT ["bind-operator"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..81a452c --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: build test lint fmt generate manifests docker-operator clean tidy patch minor major + +BINARY_OP := bin/bind-operator +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") + +build: tidy + go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator + +test: + go test -race -count=1 ./api/... ./internal/... + +lint: + go vet ./... + +fmt: + gofmt -w . + +## generate: regenerate deepcopy, CRDs and RBAC from kubebuilder markers +generate: + controller-gen object paths="./api/..." + controller-gen crd paths="./api/..." output:crd:artifacts:config=config/crd/bases + controller-gen rbac:roleName=bind-operator paths="./internal/controller/..." output:rbac:dir=config/rbac + +manifests: generate + +docker-operator: + docker build -t bind-operator:$(VERSION) -f Dockerfile.operator . + +clean: + rm -rf bin/ + +tidy: + go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md index a39f7d3..8fc5d7c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,98 @@ # bind-operator -Kubernetes operator for managing BIND9 DNS clusters, zones, views, and TSIG keys \ No newline at end of file +A Kubernetes operator that manages fleets of BIND9 servers declaratively: +StatefulSet-backed clusters with primary/secondary replication, and zones, +views, TSIG keys, ACLs, catalog zones, RPZ policies and DNSSEC policies as +custom resources. + +## Architecture + +Each `BindCluster` is a StatefulSet plus a headless Service (stable per-pod DNS) +and a client Service. **Ordinal-0 is the primary**; the remaining pods are +secondaries that replicate via AXFR/IXFR + NOTIFY. A per-pod PVC holds zone +databases and journals. + +Zone content is delivered **dynamically**: the operator execs `rndc addzone` and +TSIG `nsupdate` against the primary pod (the same write path external-dns uses). +Cluster-wide config — `options`, `controls`, ACLs, views, `dnssec-policy` blocks +and `response-policy` clauses — is rendered into a ConfigMap-backed `named.conf` +and reloaded with `rndc reconfig`. New zones land on the secondaries +automatically through a **catalog zone**, so secondaries never need +per-zone reconfiguration. + +``` +BindCluster ──> StatefulSet (pod-0 = primary, pod-N = secondaries) + ├─ headless Service (pod-0.-headless..svc…) + ├─ client Service (ClusterIP / LoadBalancer) + ├─ ConfigMap (named.conf.primary / .secondary + entrypoint) + ├─ Secret -keys (TSIG key clauses, included by named.conf) + └─ Secret -rndc (rndc control key) + +BindZone / DNSRecord ──rndc addzone + nsupdate──> primary ──catalog + AXFR──> secondaries +``` + +The named.conf is rendered in two variants (primary/secondary); an entrypoint +script picks one based on the pod ordinal. + +## Custom Resources + +| Kind | Purpose | +|------|---------| +| `BindCluster` | A set of BIND9 servers. `spec.mode`: `authoritative`, `resolver`, or `dynamic`. | +| `BindZone` | A forward/reverse zone (`primary`/`secondary`/`forward`/`stub`), records inline, optional dynamic-update + DNSSEC + catalog membership. | +| `DNSRecord` | A single record set applied via TSIG `nsupdate` — external-dns as a CRD. | +| `BindView` | A split-horizon view (`match-clients`, ordering, per-view recursion). | +| `BindTSIGKey` | A TSIG key; the operator generates material into a Secret (never stored in the CR). | +| `BindACL` | A reusable named `address_match_list`. | +| `BindCatalogZone` | A BIND catalog zone so secondaries auto-provision member zones. | +| `BindPolicy` | A Response Policy Zone (RPZ) / DNS firewall. | +| `BindDNSSECPolicy` | A `dnssec-policy` for automated signing. | + +See `config/samples/` for worked examples. + +## Migration mapping + +The three Puppet-managed BIND roles map onto three `BindCluster`s: + +| Puppet role | `BindCluster` | Mode | +|-------------|---------------|------| +| 3× authoritative masters | `auth` | `authoritative` (pod-0 primary, 2 secondaries) | +| 3× only-resolvers | `resolver` | `resolver` (3 identical recursive servers) | +| 3× external-dns | `externaldns` | `dynamic` (RFC2136 TSIG updates on primary) | + +## Development + +```sh +make generate # regenerate deepcopy, CRDs and RBAC from kubebuilder markers +make build # build the operator binary +make test # go test -race +make lint fmt # go vet / gofmt +``` + +### Local (kind) + +```sh +kind create cluster --name bind +docker build -t bind-operator:dev -f Dockerfile.operator . +kind load docker-image bind-operator:dev --name bind + +kubectl apply -f config/crd/bases/ +kubectl apply -f hack/kind/manifests/ +kubectl apply -f config/samples/ +``` + +## CI + +Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build` +on pull requests; pushing a `v*` tag builds and pushes +`git.unkin.net/unkin/bind-operator` to the Gitea registry. + +## Notes & caveats + +- The BIND container image (`spec.image`, default + `git.unkin.net/unkin/bind9:latest`) must ship `named`, `rndc` and `nsupdate`, + read `/run/named/named.conf`, and honour the operator's `/etc/bind` layout. +- Dynamic updates authenticate with `nsupdate -y`; the TSIG secret is passed on + the argv of an exec'd process inside the pod. +- RPZ IP-trigger encodings (`ip`, `client-ip`, `nsip`) are emitted verbatim; + QNAME and NSDNAME triggers are fully supported. diff --git a/api/v1alpha1/bindacl_types.go b/api/v1alpha1/bindacl_types.go new file mode 100644 index 0000000..7fa336b --- /dev/null +++ b/api/v1alpha1/bindacl_types.go @@ -0,0 +1,61 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BindACLSpec defines a reusable named address_match_list. +type BindACLSpec struct { + // ClusterRef names the BindCluster whose named.conf this ACL is rendered + // into. When empty the ACL is available to every cluster in the namespace. + // +optional + ClusterRef string `json:"clusterRef,omitempty"` + + // Entries are raw BIND address-match-list elements, e.g. "10.0.0.0/8", + // "!192.168.1.5", "key transfer-key", "localhost", "any", or the name of + // another ACL. + // +kubebuilder:validation:MinItems=1 + Entries []string `json:"entries"` +} + +// BindACLStatus reports observed ACL state. +type BindACLStatus struct { + // +optional + Ready bool `json:"ready,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bacl +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Entries",type=integer,JSONPath=`.spec.entries[*]` +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` + +// BindACL is a named address-match-list referenced by views, zones and +// policies for match-clients / allow-query / allow-transfer / allow-update. +type BindACL struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindACLSpec `json:"spec,omitempty"` + Status BindACLStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindACLList contains a list of BindACL. +type BindACLList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindACL `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindACL{}, &BindACLList{}) +} diff --git a/api/v1alpha1/bindcatalogzone_types.go b/api/v1alpha1/bindcatalogzone_types.go new file mode 100644 index 0000000..9912c81 --- /dev/null +++ b/api/v1alpha1/bindcatalogzone_types.go @@ -0,0 +1,71 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BindCatalogZoneSpec defines a BIND9 catalog zone. The primary publishes it +// and secondaries consume it, so member zones are provisioned onto every +// secondary automatically without per-zone reconfiguration. +type BindCatalogZoneSpec struct { + // ClusterRef names the owning BindCluster. + ClusterRef string `json:"clusterRef"` + + // ZoneName is the catalog zone's own origin, e.g. "catalog.internal". + ZoneName string `json:"zoneName"` + + // DefaultPrimaries are the addresses member zones point at on secondaries. + // Defaults to the cluster primary Service. + // +optional + DefaultPrimaries []string `json:"defaultPrimaries,omitempty"` + + // TransferKeyRef names the BindTSIGKey authenticating catalog + member zone + // transfers to secondaries. + // +optional + TransferKeyRef string `json:"transferKeyRef,omitempty"` +} + +// BindCatalogZoneStatus reports observed catalog state. +type BindCatalogZoneStatus struct { + // +optional + Ready bool `json:"ready,omitempty"` + // MemberCount is the number of member zones registered in the catalog. + // +optional + MemberCount int32 `json:"memberCount,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bcz +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneName` +// +kubebuilder:printcolumn:name="Members",type=integer,JSONPath=`.status.memberCount` +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` + +// BindCatalogZone auto-provisions member zones onto cluster secondaries. +type BindCatalogZone struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindCatalogZoneSpec `json:"spec,omitempty"` + Status BindCatalogZoneStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindCatalogZoneList contains a list of BindCatalogZone. +type BindCatalogZoneList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindCatalogZone `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindCatalogZone{}, &BindCatalogZoneList{}) +} diff --git a/api/v1alpha1/bindcluster_types.go b/api/v1alpha1/bindcluster_types.go new file mode 100644 index 0000000..b747ede --- /dev/null +++ b/api/v1alpha1/bindcluster_types.go @@ -0,0 +1,181 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BindMode selects the behaviour of a BindCluster and maps onto a classic +// BIND deployment role. +// - authoritative: serves signed/unsigned authoritative zones. Ordinal-0 is +// the primary that holds zone data; the remaining pods are secondaries that +// replicate via AXFR/IXFR + NOTIFY (optionally driven by a catalog zone). +// - resolver: N identical recursive resolvers, no zone replication. +// - dynamic: like authoritative, but the primary accepts RFC2136 TSIG updates +// (the external-dns pattern); secondaries replicate the result. +// +// +kubebuilder:validation:Enum=authoritative;resolver;dynamic +type BindMode string + +const ( + ModeAuthoritative BindMode = "authoritative" + ModeResolver BindMode = "resolver" + ModeDynamic BindMode = "dynamic" +) + +// ClusterServiceSpec controls how the cluster is exposed to clients. +type ClusterServiceSpec struct { + // Type of the client-facing Service. Defaults to ClusterIP. + // +kubebuilder:validation:Enum=ClusterIP;LoadBalancer;NodePort + // +optional + Type corev1.ServiceType `json:"type,omitempty"` + + // LoadBalancerIP requests a specific address when Type is LoadBalancer. + // +optional + LoadBalancerIP string `json:"loadBalancerIP,omitempty"` + + // Annotations added to the client-facing Service (e.g. PureLB/MetalLB hints). + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +// BindClusterSpec defines the desired state of a BIND cluster. +type BindClusterSpec struct { + // Mode selects the cluster role. + // +kubebuilder:default=authoritative + Mode BindMode `json:"mode"` + + // Replicas is the number of BIND pods. Ordinal-0 is the primary for the + // authoritative and dynamic modes. + // +kubebuilder:default=3 + // +kubebuilder:validation:Minimum=1 + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Image is the BIND9 container image. + // +kubebuilder:default="git.unkin.net/unkin/bind9:latest" + // +optional + Image string `json:"image,omitempty"` + + // ImagePullPolicy for the BIND container. + // +optional + ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` + + // Recursion overrides the default per-mode recursion setting. When nil, + // resolver mode enables recursion and the other modes disable it. + // +optional + Recursion *bool `json:"recursion,omitempty"` + + // Forwarders is a list of upstream resolvers used by resolver mode (and any + // forward zones that do not specify their own). + // +optional + Forwarders []string `json:"forwarders,omitempty"` + + // AllowNewZones enables the rndc addzone/delzone control path required for + // dynamic zone provisioning. Defaults to true. + // +kubebuilder:default=true + // +optional + AllowNewZones *bool `json:"allowNewZones,omitempty"` + + // CatalogZoneRef names a BindCatalogZone that secondaries consume so member + // zones are auto-provisioned without per-zone reconfiguration. + // +optional + CatalogZoneRef string `json:"catalogZoneRef,omitempty"` + + // ExtraOptions are raw named.conf `options { ... }` lines appended verbatim. + // +optional + ExtraOptions []string `json:"extraOptions,omitempty"` + + // StorageClassName for the per-pod PVC that holds zone data and journals. + // +optional + StorageClassName *string `json:"storageClassName,omitempty"` + + // StorageSize for the per-pod PVC. Defaults to 1Gi. + // +kubebuilder:default="1Gi" + // +optional + StorageSize string `json:"storageSize,omitempty"` + + // Resources for the BIND container. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // Service controls how the cluster is exposed. + // +optional + Service ClusterServiceSpec `json:"service,omitempty"` + + // NodeSelector for the BIND pods. + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // Tolerations for the BIND pods. + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Affinity for the BIND pods. + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` +} + +// BindClusterStatus reports observed cluster state. +type BindClusterStatus struct { + // Phase is a coarse lifecycle summary. + // +optional + Phase string `json:"phase,omitempty"` + + // Replicas is the number of BIND pods requested. + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // ReadyReplicas is the number of BIND pods currently ready. + // +optional + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + + // PrimaryPod is the pod that holds authoritative zone data (ordinal-0). + // +optional + PrimaryPod string `json:"primaryPod,omitempty"` + + // PrimaryService is the in-cluster DNS name secondaries transfer from. + // +optional + PrimaryService string `json:"primaryService,omitempty"` + + // ObservedGeneration is the last reconciled generation. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions represent the latest available observations. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bc +// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.mode` +// +kubebuilder:printcolumn:name="Desired",type=integer,JSONPath=`.spec.replicas` +// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas` +// +kubebuilder:printcolumn:name="Primary",type=string,JSONPath=`.status.primaryPod` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// BindCluster is a managed set of BIND9 servers. +type BindCluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindClusterSpec `json:"spec,omitempty"` + Status BindClusterStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindClusterList contains a list of BindCluster. +type BindClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindCluster `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindCluster{}, &BindClusterList{}) +} diff --git a/api/v1alpha1/binddnssecpolicy_types.go b/api/v1alpha1/binddnssecpolicy_types.go new file mode 100644 index 0000000..8c11d4a --- /dev/null +++ b/api/v1alpha1/binddnssecpolicy_types.go @@ -0,0 +1,111 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DNSSECKey describes a key in a signing policy. +type DNSSECKey struct { + // Lifetime is how long the key is used before rollover, e.g. "P30D" or + // "unlimited". Empty means unlimited. + // +optional + Lifetime string `json:"lifetime,omitempty"` + + // Algorithm overrides the policy algorithm for this key. + // +optional + Algorithm string `json:"algorithm,omitempty"` + + // KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA). + // +optional + KeySize int32 `json:"keySize,omitempty"` +} + +// BindDNSSECPolicySpec mirrors a BIND9 dnssec-policy. Zones referencing it are +// signed with inline-signing and automated key management. +type BindDNSSECPolicySpec struct { + // ClusterRef names the owning BindCluster. + ClusterRef string `json:"clusterRef"` + + // PolicyName is the dnssec-policy name in named.conf. Defaults to the object + // name. + // +optional + PolicyName string `json:"policyName,omitempty"` + + // Algorithm for signing. Defaults to ecdsap256sha256. + // +kubebuilder:default="ecdsap256sha256" + // +optional + Algorithm string `json:"algorithm,omitempty"` + + // CSK, when set, uses a Combined Signing Key instead of split KSK/ZSK. + // +optional + CSK *DNSSECKey `json:"csk,omitempty"` + + // KSK is the Key Signing Key configuration (ignored when CSK is set). + // +optional + KSK *DNSSECKey `json:"ksk,omitempty"` + + // ZSK is the Zone Signing Key configuration (ignored when CSK is set). + // +optional + ZSK *DNSSECKey `json:"zsk,omitempty"` + + // NSEC3 enables NSEC3 hashing instead of NSEC. + // +optional + NSEC3 bool `json:"nsec3,omitempty"` + + // MaxZoneTTL, e.g. "P1D". + // +optional + MaxZoneTTL string `json:"maxZoneTTL,omitempty"` + + // SignaturesValidity, e.g. "P14D". + // +optional + SignaturesValidity string `json:"signaturesValidity,omitempty"` + + // ExtraOptions are raw named.conf lines appended inside the policy block. + // +optional + ExtraOptions []string `json:"extraOptions,omitempty"` +} + +// BindDNSSECPolicyStatus reports observed policy state. +type BindDNSSECPolicyStatus struct { + // +optional + Ready bool `json:"ready,omitempty"` + // ZoneCount is the number of zones signed with this policy. + // +optional + ZoneCount int32 `json:"zoneCount,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bdp +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Algorithm",type=string,JSONPath=`.spec.algorithm` +// +kubebuilder:printcolumn:name="Zones",type=integer,JSONPath=`.status.zoneCount` +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` + +// BindDNSSECPolicy is a reusable DNSSEC signing policy. +type BindDNSSECPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindDNSSECPolicySpec `json:"spec,omitempty"` + Status BindDNSSECPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindDNSSECPolicyList contains a list of BindDNSSECPolicy. +type BindDNSSECPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindDNSSECPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindDNSSECPolicy{}, &BindDNSSECPolicyList{}) +} diff --git a/api/v1alpha1/bindpolicy_types.go b/api/v1alpha1/bindpolicy_types.go new file mode 100644 index 0000000..c3a6cf0 --- /dev/null +++ b/api/v1alpha1/bindpolicy_types.go @@ -0,0 +1,110 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RPZTrigger is the match domain of a response-policy rule. +// +kubebuilder:validation:Enum=qname;client-ip;ip;nsdname;nsip +type RPZTrigger string + +// RPZAction is the policy action taken on a match. +// +kubebuilder:validation:Enum=nxdomain;nodata;passthru;drop;tcp-only;cname +type RPZAction string + +// RPZRule is a single response-policy rule. +type RPZRule struct { + // Trigger selects what the Match is compared against. + // +kubebuilder:default=qname + // +optional + Trigger RPZTrigger `json:"trigger,omitempty"` + + // Match is the trigger value, e.g. a domain "bad.example." or CIDR. + Match string `json:"match"` + + // Action taken when the rule matches. + // +kubebuilder:default=nxdomain + // +optional + Action RPZAction `json:"action,omitempty"` + + // Target is the rewrite target when Action is cname. + // +optional + Target string `json:"target,omitempty"` +} + +// BindPolicySpec defines a Response Policy Zone (RPZ) — a DNS firewall applied +// to a resolver cluster. +type BindPolicySpec struct { + // ClusterRef names the owning BindCluster (typically a resolver). + ClusterRef string `json:"clusterRef"` + + // ViewRef optionally scopes the policy to a single view. + // +optional + ViewRef string `json:"viewRef,omitempty"` + + // ZoneName is the RPZ zone origin, e.g. "rpz.internal". + ZoneName string `json:"zoneName"` + + // Order controls this policy's position in the response-policy clause. + // +kubebuilder:default=100 + // +optional + Order int32 `json:"order,omitempty"` + + // Rules are the inline policy triggers. + // +optional + Rules []RPZRule `json:"rules,omitempty"` + + // Primaries lets the RPZ zone be transferred from an external feed instead + // of being locally populated. + // +optional + Primaries []string `json:"primaries,omitempty"` + + // TransferKeyRef names the BindTSIGKey used to pull from Primaries. + // +optional + TransferKeyRef string `json:"transferKeyRef,omitempty"` +} + +// BindPolicyStatus reports observed policy state. +type BindPolicyStatus struct { + // +optional + Ready bool `json:"ready,omitempty"` + // RuleCount is the number of active rules. + // +optional + RuleCount int32 `json:"ruleCount,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bp +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneName` +// +kubebuilder:printcolumn:name="Rules",type=integer,JSONPath=`.status.ruleCount` +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` + +// BindPolicy is a Response Policy Zone (RPZ) applied to a cluster. +type BindPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindPolicySpec `json:"spec,omitempty"` + Status BindPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindPolicyList contains a list of BindPolicy. +type BindPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindPolicy{}, &BindPolicyList{}) +} diff --git a/api/v1alpha1/bindtsigkey_types.go b/api/v1alpha1/bindtsigkey_types.go new file mode 100644 index 0000000..d454219 --- /dev/null +++ b/api/v1alpha1/bindtsigkey_types.go @@ -0,0 +1,92 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TSIGAlgorithm is a supported TSIG HMAC algorithm. +// +kubebuilder:validation:Enum=hmac-sha256;hmac-sha512;hmac-sha384;hmac-sha224;hmac-sha1;hmac-md5 +type TSIGAlgorithm string + +const ( + TSIGHMACSHA256 TSIGAlgorithm = "hmac-sha256" + TSIGHMACSHA512 TSIGAlgorithm = "hmac-sha512" +) + +// BindTSIGKeySpec defines a TSIG key. If no existing key material is imported, +// the operator generates a random key and stores it in a Secret. +type BindTSIGKeySpec struct { + // Algorithm is the HMAC algorithm. Defaults to hmac-sha256. + // +kubebuilder:default="hmac-sha256" + // +optional + Algorithm TSIGAlgorithm `json:"algorithm,omitempty"` + + // KeyName is the TSIG key name emitted into named.conf. Defaults to the + // object name. + // +optional + KeyName string `json:"keyName,omitempty"` + + // SecretName is the Secret the key material is written to (or read from when + // ImportExisting is set). Defaults to "-tsig". + // +optional + SecretName string `json:"secretName,omitempty"` + + // ImportExisting, when true, means the referenced Secret already contains a + // `secret` key and the operator will not generate new material. + // +optional + ImportExisting bool `json:"importExisting,omitempty"` +} + +// BindTSIGKeyStatus reports observed TSIG key state. +type BindTSIGKeyStatus struct { + // SecretName holds the generated/managed key material. + // +optional + SecretName string `json:"secretName,omitempty"` + + // KeyName as used in named.conf. + // +optional + KeyName string `json:"keyName,omitempty"` + + // Ready is true once the key Secret exists. + // +optional + Ready bool `json:"ready,omitempty"` + + // ObservedGeneration is the last reconciled generation. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=btk +// +kubebuilder:printcolumn:name="Algorithm",type=string,JSONPath=`.spec.algorithm` +// +kubebuilder:printcolumn:name="Secret",type=string,JSONPath=`.status.secretName` +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` + +// BindTSIGKey is a TSIG key backing zone transfers, dynamic updates and view +// matching. The key material lives in a Kubernetes Secret, never in the CR. +type BindTSIGKey struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindTSIGKeySpec `json:"spec,omitempty"` + Status BindTSIGKeyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindTSIGKeyList contains a list of BindTSIGKey. +type BindTSIGKeyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindTSIGKey `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindTSIGKey{}, &BindTSIGKeyList{}) +} diff --git a/api/v1alpha1/bindview_types.go b/api/v1alpha1/bindview_types.go new file mode 100644 index 0000000..b5361c0 --- /dev/null +++ b/api/v1alpha1/bindview_types.go @@ -0,0 +1,84 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BindViewSpec defines a split-horizon view. View ordering is significant in +// BIND; use Order to control the sequence in named.conf. +type BindViewSpec struct { + // ClusterRef names the owning BindCluster. + ClusterRef string `json:"clusterRef"` + + // Order controls the position of this view in named.conf (ascending). The + // first view whose match-clients matches a query wins. + // +kubebuilder:default=100 + // +optional + Order int32 `json:"order,omitempty"` + + // MatchClients is an address-match-list (inline entries and/or ACL names) + // selecting which clients this view answers. Defaults to "any". + // +optional + MatchClients []string `json:"matchClients,omitempty"` + + // MatchDestinations is an optional destination address-match-list. + // +optional + MatchDestinations []string `json:"matchDestinations,omitempty"` + + // Recursion overrides the cluster recursion setting for this view. + // +optional + Recursion *bool `json:"recursion,omitempty"` + + // AllowQuery is an address-match-list restricting queries into this view. + // +optional + AllowQuery []string `json:"allowQuery,omitempty"` + + // ExtraOptions are raw named.conf lines appended inside the view block. + // +optional + ExtraOptions []string `json:"extraOptions,omitempty"` +} + +// BindViewStatus reports observed view state. +type BindViewStatus struct { + // +optional + Ready bool `json:"ready,omitempty"` + // ZoneCount is the number of zones currently bound to this view. + // +optional + ZoneCount int32 `json:"zoneCount,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bv +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Order",type=integer,JSONPath=`.spec.order` +// +kubebuilder:printcolumn:name="Zones",type=integer,JSONPath=`.status.zoneCount` +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` + +// BindView is a split-horizon view on a BindCluster. +type BindView struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindViewSpec `json:"spec,omitempty"` + Status BindViewStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindViewList contains a list of BindView. +type BindViewList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindView `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindView{}, &BindViewList{}) +} diff --git a/api/v1alpha1/bindzone_types.go b/api/v1alpha1/bindzone_types.go new file mode 100644 index 0000000..0942e1c --- /dev/null +++ b/api/v1alpha1/bindzone_types.go @@ -0,0 +1,156 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ZoneType is the BIND zone type. +// +kubebuilder:validation:Enum=primary;secondary;forward;stub +type ZoneType string + +const ( + ZonePrimary ZoneType = "primary" + ZoneSecondary ZoneType = "secondary" + ZoneForward ZoneType = "forward" + ZoneStub ZoneType = "stub" +) + +// Record is a single resource record set seeded into a primary zone via +// dynamic update (nsupdate). Ongoing changes may also arrive from DNSRecord +// objects or external RFC2136 clients. +type Record struct { + // Name is the owner name, relative to the zone apex or fully qualified. + // Use "@" for the apex. + // +kubebuilder:default="@" + // +optional + Name string `json:"name,omitempty"` + + // Type is the RR type, e.g. A, AAAA, CNAME, MX, TXT, SRV, NS, PTR, CAA. + Type string `json:"type"` + + // TTL for the record set in seconds. Falls back to the zone default TTL. + // +optional + TTL *int32 `json:"ttl,omitempty"` + + // Values are the RDATA entries, e.g. ["10 mail.example.com."] for an MX or + // ["192.0.2.1","192.0.2.2"] for an A round-robin. + // +kubebuilder:validation:MinItems=1 + Values []string `json:"values"` +} + +// BindZoneSpec defines a DNS zone managed on a BindCluster's primary. +type BindZoneSpec struct { + // ClusterRef names the owning BindCluster. + ClusterRef string `json:"clusterRef"` + + // ViewRef optionally binds this zone to a BindView. + // +optional + ViewRef string `json:"viewRef,omitempty"` + + // ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa". + ZoneName string `json:"zoneName"` + + // Type is the zone type. Defaults to primary. + // +kubebuilder:default=primary + // +optional + Type ZoneType `json:"type,omitempty"` + + // DefaultTTL for records that do not set their own TTL. Defaults to 3600. + // +kubebuilder:default=3600 + // +optional + DefaultTTL int32 `json:"defaultTTL,omitempty"` + + // Records are static record sets seeded into a primary zone. + // +optional + Records []Record `json:"records,omitempty"` + + // DynamicUpdate enables RFC2136 updates for this zone (external-dns style). + // When true, UpdateKeyRef must reference a BindTSIGKey. + // +optional + DynamicUpdate bool `json:"dynamicUpdate,omitempty"` + + // UpdateKeyRef names the BindTSIGKey permitted to send dynamic updates. + // +optional + UpdateKeyRef string `json:"updateKeyRef,omitempty"` + + // AllowTransfer is an address-match-list (inline entries and/or ACL/key + // names) permitted to AXFR/IXFR this zone. + // +optional + AllowTransfer []string `json:"allowTransfer,omitempty"` + + // Catalog, when true, registers this zone as a member of the cluster's + // catalog zone so secondaries auto-provision it. + // +kubebuilder:default=true + // +optional + Catalog *bool `json:"catalog,omitempty"` + + // DNSSECPolicyRef names a BindDNSSECPolicy to sign this zone with. + // +optional + DNSSECPolicyRef string `json:"dnssecPolicyRef,omitempty"` + + // Forwarders lists upstreams for a forward-type zone. + // +optional + Forwarders []string `json:"forwarders,omitempty"` + + // Primaries lists source servers for a secondary/stub-type zone. + // +optional + Primaries []string `json:"primaries,omitempty"` + + // TransferKeyRef names the BindTSIGKey used to authenticate transfers from + // Primaries for a secondary zone. + // +optional + TransferKeyRef string `json:"transferKeyRef,omitempty"` +} + +// BindZoneStatus reports observed zone state. +type BindZoneStatus struct { + // Phase is a coarse lifecycle summary (Pending/Ready/Error). + // +optional + Phase string `json:"phase,omitempty"` + // Serial is the last observed SOA serial on the primary. + // +optional + Serial int64 `json:"serial,omitempty"` + // RecordCount is the number of managed record sets applied. + // +optional + RecordCount int32 `json:"recordCount,omitempty"` + // Signed reports whether DNSSEC signing is active. + // +optional + Signed bool `json:"signed,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=bz +// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneName` +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef` +// +kubebuilder:printcolumn:name="Serial",type=integer,JSONPath=`.status.serial` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// BindZone is a forward or reverse DNS zone. +type BindZone struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BindZoneSpec `json:"spec,omitempty"` + Status BindZoneStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BindZoneList contains a list of BindZone. +type BindZoneList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BindZone `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BindZone{}, &BindZoneList{}) +} diff --git a/api/v1alpha1/dnsrecord_types.go b/api/v1alpha1/dnsrecord_types.go new file mode 100644 index 0000000..99dc3ac --- /dev/null +++ b/api/v1alpha1/dnsrecord_types.go @@ -0,0 +1,75 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DNSRecordSpec defines a single record set applied to a zone via TSIG dynamic +// update (nsupdate) — the external-dns write path expressed as a CRD. +type DNSRecordSpec struct { + // ZoneRef names the BindZone this record belongs to. The cluster, view and + // update key are derived from the referenced zone. + ZoneRef string `json:"zoneRef"` + + // Name is the owner name, relative to the zone apex or fully qualified. + // +kubebuilder:default="@" + // +optional + Name string `json:"name,omitempty"` + + // Type is the RR type, e.g. A, AAAA, CNAME, TXT, SRV, MX. + Type string `json:"type"` + + // TTL for the record set in seconds. Falls back to the zone default TTL. + // +optional + TTL *int32 `json:"ttl,omitempty"` + + // Values are the RDATA entries. + // +kubebuilder:validation:MinItems=1 + Values []string `json:"values"` +} + +// DNSRecordStatus reports observed record state. +type DNSRecordStatus struct { + // Phase is a coarse lifecycle summary (Pending/Applied/Error). + // +optional + Phase string `json:"phase,omitempty"` + // FQDN is the fully-qualified owner name that was applied. + // +optional + FQDN string `json:"fqdn,omitempty"` + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=dnsr +// +kubebuilder:printcolumn:name="Zone",type=string,JSONPath=`.spec.zoneRef` +// +kubebuilder:printcolumn:name="Name",type=string,JSONPath=`.spec.name` +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// DNSRecord is an individually-managed record set applied to a BindZone. +type DNSRecord struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DNSRecordSpec `json:"spec,omitempty"` + Status DNSRecordStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DNSRecordList contains a list of DNSRecord. +type DNSRecordList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSRecord `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DNSRecord{}, &DNSRecordList{}) +} diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go new file mode 100644 index 0000000..2a7604b --- /dev/null +++ b/api/v1alpha1/doc.go @@ -0,0 +1,3 @@ +// +kubebuilder:object:generate=true +// +groupName=bind.unkin.net +package v1alpha1 diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..6e4356f --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,17 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is the group/version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "bind.unkin.net", Version: "v1alpha1"} + + // SchemeBuilder registers the API types with a runtime scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..05e2ff8 --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,1100 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindACL) DeepCopyInto(out *BindACL) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindACL. +func (in *BindACL) DeepCopy() *BindACL { + if in == nil { + return nil + } + out := new(BindACL) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindACL) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindACLList) DeepCopyInto(out *BindACLList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindACL, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindACLList. +func (in *BindACLList) DeepCopy() *BindACLList { + if in == nil { + return nil + } + out := new(BindACLList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindACLList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindACLSpec) DeepCopyInto(out *BindACLSpec) { + *out = *in + if in.Entries != nil { + in, out := &in.Entries, &out.Entries + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindACLSpec. +func (in *BindACLSpec) DeepCopy() *BindACLSpec { + if in == nil { + return nil + } + out := new(BindACLSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindACLStatus) DeepCopyInto(out *BindACLStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindACLStatus. +func (in *BindACLStatus) DeepCopy() *BindACLStatus { + if in == nil { + return nil + } + out := new(BindACLStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindCatalogZone) DeepCopyInto(out *BindCatalogZone) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindCatalogZone. +func (in *BindCatalogZone) DeepCopy() *BindCatalogZone { + if in == nil { + return nil + } + out := new(BindCatalogZone) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindCatalogZone) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindCatalogZoneList) DeepCopyInto(out *BindCatalogZoneList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindCatalogZone, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindCatalogZoneList. +func (in *BindCatalogZoneList) DeepCopy() *BindCatalogZoneList { + if in == nil { + return nil + } + out := new(BindCatalogZoneList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindCatalogZoneList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindCatalogZoneSpec) DeepCopyInto(out *BindCatalogZoneSpec) { + *out = *in + if in.DefaultPrimaries != nil { + in, out := &in.DefaultPrimaries, &out.DefaultPrimaries + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindCatalogZoneSpec. +func (in *BindCatalogZoneSpec) DeepCopy() *BindCatalogZoneSpec { + if in == nil { + return nil + } + out := new(BindCatalogZoneSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindCatalogZoneStatus) DeepCopyInto(out *BindCatalogZoneStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindCatalogZoneStatus. +func (in *BindCatalogZoneStatus) DeepCopy() *BindCatalogZoneStatus { + if in == nil { + return nil + } + out := new(BindCatalogZoneStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindCluster) DeepCopyInto(out *BindCluster) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindCluster. +func (in *BindCluster) DeepCopy() *BindCluster { + if in == nil { + return nil + } + out := new(BindCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindCluster) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindClusterList) DeepCopyInto(out *BindClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindCluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindClusterList. +func (in *BindClusterList) DeepCopy() *BindClusterList { + if in == nil { + return nil + } + out := new(BindClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindClusterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindClusterSpec) DeepCopyInto(out *BindClusterSpec) { + *out = *in + if in.Recursion != nil { + in, out := &in.Recursion, &out.Recursion + *out = new(bool) + **out = **in + } + if in.Forwarders != nil { + in, out := &in.Forwarders, &out.Forwarders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowNewZones != nil { + in, out := &in.AllowNewZones, &out.AllowNewZones + *out = new(bool) + **out = **in + } + if in.ExtraOptions != nil { + in, out := &in.ExtraOptions, &out.ExtraOptions + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.StorageClassName != nil { + in, out := &in.StorageClassName, &out.StorageClassName + *out = new(string) + **out = **in + } + in.Resources.DeepCopyInto(&out.Resources) + in.Service.DeepCopyInto(&out.Service) + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindClusterSpec. +func (in *BindClusterSpec) DeepCopy() *BindClusterSpec { + if in == nil { + return nil + } + out := new(BindClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindClusterStatus) DeepCopyInto(out *BindClusterStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindClusterStatus. +func (in *BindClusterStatus) DeepCopy() *BindClusterStatus { + if in == nil { + return nil + } + out := new(BindClusterStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindDNSSECPolicy) DeepCopyInto(out *BindDNSSECPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindDNSSECPolicy. +func (in *BindDNSSECPolicy) DeepCopy() *BindDNSSECPolicy { + if in == nil { + return nil + } + out := new(BindDNSSECPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindDNSSECPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindDNSSECPolicyList) DeepCopyInto(out *BindDNSSECPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindDNSSECPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindDNSSECPolicyList. +func (in *BindDNSSECPolicyList) DeepCopy() *BindDNSSECPolicyList { + if in == nil { + return nil + } + out := new(BindDNSSECPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindDNSSECPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindDNSSECPolicySpec) DeepCopyInto(out *BindDNSSECPolicySpec) { + *out = *in + if in.CSK != nil { + in, out := &in.CSK, &out.CSK + *out = new(DNSSECKey) + **out = **in + } + if in.KSK != nil { + in, out := &in.KSK, &out.KSK + *out = new(DNSSECKey) + **out = **in + } + if in.ZSK != nil { + in, out := &in.ZSK, &out.ZSK + *out = new(DNSSECKey) + **out = **in + } + if in.ExtraOptions != nil { + in, out := &in.ExtraOptions, &out.ExtraOptions + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindDNSSECPolicySpec. +func (in *BindDNSSECPolicySpec) DeepCopy() *BindDNSSECPolicySpec { + if in == nil { + return nil + } + out := new(BindDNSSECPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindDNSSECPolicyStatus) DeepCopyInto(out *BindDNSSECPolicyStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindDNSSECPolicyStatus. +func (in *BindDNSSECPolicyStatus) DeepCopy() *BindDNSSECPolicyStatus { + if in == nil { + return nil + } + out := new(BindDNSSECPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindPolicy) DeepCopyInto(out *BindPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindPolicy. +func (in *BindPolicy) DeepCopy() *BindPolicy { + if in == nil { + return nil + } + out := new(BindPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindPolicyList) DeepCopyInto(out *BindPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindPolicyList. +func (in *BindPolicyList) DeepCopy() *BindPolicyList { + if in == nil { + return nil + } + out := new(BindPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindPolicySpec) DeepCopyInto(out *BindPolicySpec) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]RPZRule, len(*in)) + copy(*out, *in) + } + if in.Primaries != nil { + in, out := &in.Primaries, &out.Primaries + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindPolicySpec. +func (in *BindPolicySpec) DeepCopy() *BindPolicySpec { + if in == nil { + return nil + } + out := new(BindPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindPolicyStatus) DeepCopyInto(out *BindPolicyStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindPolicyStatus. +func (in *BindPolicyStatus) DeepCopy() *BindPolicyStatus { + if in == nil { + return nil + } + out := new(BindPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKey. +func (in *BindTSIGKey) DeepCopy() *BindTSIGKey { + if in == nil { + return nil + } + out := new(BindTSIGKey) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindTSIGKey) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGKeyList) DeepCopyInto(out *BindTSIGKeyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindTSIGKey, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeyList. +func (in *BindTSIGKeyList) DeepCopy() *BindTSIGKeyList { + if in == nil { + return nil + } + out := new(BindTSIGKeyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindTSIGKeyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGKeySpec) DeepCopyInto(out *BindTSIGKeySpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeySpec. +func (in *BindTSIGKeySpec) DeepCopy() *BindTSIGKeySpec { + if in == nil { + return nil + } + out := new(BindTSIGKeySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindTSIGKeyStatus) DeepCopyInto(out *BindTSIGKeyStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGKeyStatus. +func (in *BindTSIGKeyStatus) DeepCopy() *BindTSIGKeyStatus { + if in == nil { + return nil + } + out := new(BindTSIGKeyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindView) DeepCopyInto(out *BindView) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindView. +func (in *BindView) DeepCopy() *BindView { + if in == nil { + return nil + } + out := new(BindView) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindView) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindViewList) DeepCopyInto(out *BindViewList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindView, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindViewList. +func (in *BindViewList) DeepCopy() *BindViewList { + if in == nil { + return nil + } + out := new(BindViewList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindViewList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindViewSpec) DeepCopyInto(out *BindViewSpec) { + *out = *in + if in.MatchClients != nil { + in, out := &in.MatchClients, &out.MatchClients + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.MatchDestinations != nil { + in, out := &in.MatchDestinations, &out.MatchDestinations + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Recursion != nil { + in, out := &in.Recursion, &out.Recursion + *out = new(bool) + **out = **in + } + if in.AllowQuery != nil { + in, out := &in.AllowQuery, &out.AllowQuery + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExtraOptions != nil { + in, out := &in.ExtraOptions, &out.ExtraOptions + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindViewSpec. +func (in *BindViewSpec) DeepCopy() *BindViewSpec { + if in == nil { + return nil + } + out := new(BindViewSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindViewStatus) DeepCopyInto(out *BindViewStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindViewStatus. +func (in *BindViewStatus) DeepCopy() *BindViewStatus { + if in == nil { + return nil + } + out := new(BindViewStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindZone) DeepCopyInto(out *BindZone) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindZone. +func (in *BindZone) DeepCopy() *BindZone { + if in == nil { + return nil + } + out := new(BindZone) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindZone) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindZoneList) DeepCopyInto(out *BindZoneList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BindZone, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindZoneList. +func (in *BindZoneList) DeepCopy() *BindZoneList { + if in == nil { + return nil + } + out := new(BindZoneList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BindZoneList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) { + *out = *in + if in.Records != nil { + in, out := &in.Records, &out.Records + *out = make([]Record, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AllowTransfer != nil { + in, out := &in.AllowTransfer, &out.AllowTransfer + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Catalog != nil { + in, out := &in.Catalog, &out.Catalog + *out = new(bool) + **out = **in + } + if in.Forwarders != nil { + in, out := &in.Forwarders, &out.Forwarders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Primaries != nil { + in, out := &in.Primaries, &out.Primaries + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindZoneSpec. +func (in *BindZoneSpec) DeepCopy() *BindZoneSpec { + if in == nil { + return nil + } + out := new(BindZoneSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindZoneStatus) DeepCopyInto(out *BindZoneStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindZoneStatus. +func (in *BindZoneStatus) DeepCopy() *BindZoneStatus { + if in == nil { + return nil + } + out := new(BindZoneStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterServiceSpec) DeepCopyInto(out *ClusterServiceSpec) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterServiceSpec. +func (in *ClusterServiceSpec) DeepCopy() *ClusterServiceSpec { + if in == nil { + return nil + } + out := new(ClusterServiceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecord) DeepCopyInto(out *DNSRecord) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecord. +func (in *DNSRecord) DeepCopy() *DNSRecord { + if in == nil { + return nil + } + out := new(DNSRecord) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRecord) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordList) DeepCopyInto(out *DNSRecordList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSRecord, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordList. +func (in *DNSRecordList) DeepCopy() *DNSRecordList { + if in == nil { + return nil + } + out := new(DNSRecordList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRecordList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordSpec) DeepCopyInto(out *DNSRecordSpec) { + *out = *in + if in.TTL != nil { + in, out := &in.TTL, &out.TTL + *out = new(int32) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordSpec. +func (in *DNSRecordSpec) DeepCopy() *DNSRecordSpec { + if in == nil { + return nil + } + out := new(DNSRecordSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordStatus) DeepCopyInto(out *DNSRecordStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordStatus. +func (in *DNSRecordStatus) DeepCopy() *DNSRecordStatus { + if in == nil { + return nil + } + out := new(DNSRecordStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSSECKey) DeepCopyInto(out *DNSSECKey) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSECKey. +func (in *DNSSECKey) DeepCopy() *DNSSECKey { + if in == nil { + return nil + } + out := new(DNSSECKey) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RPZRule) DeepCopyInto(out *RPZRule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RPZRule. +func (in *RPZRule) DeepCopy() *RPZRule { + if in == nil { + return nil + } + out := new(RPZRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Record) DeepCopyInto(out *Record) { + *out = *in + if in.TTL != nil { + in, out := &in.TTL, &out.TTL + *out = new(int32) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Record. +func (in *Record) DeepCopy() *Record { + if in == nil { + return nil + } + out := new(Record) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/operator/main.go b/cmd/operator/main.go new file mode 100644 index 0000000..33b5f14 --- /dev/null +++ b/cmd/operator/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" + "git.unkin.net/unkin/bind-operator/internal/controller" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(bindv1alpha1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr, probeAddr string + var leaderElect bool + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics endpoint address") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe address") + flag.BoolVar(&leaderElect, "leader-elect", false, "enable leader election") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseDevMode(false))) + logger := ctrl.Log.WithName("setup") + + restConfig := ctrl.GetConfigOrDie() + + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: leaderElect, + LeaderElectionID: "bind-operator", + }) + if err != nil { + logger.Error(err, "unable to create manager") + os.Exit(1) + } + + executor, err := bind.NewExecutor(restConfig) + if err != nil { + logger.Error(err, "unable to create pod executor") + os.Exit(1) + } + + if err := controller.SetupAll(mgr, executor); err != nil { + logger.Error(err, "unable to set up controllers") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up ready check") + os.Exit(1) + } + + logger.Info("starting bind-operator") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "manager exited with error") + os.Exit(1) + } +} diff --git a/config/crd/bases/bind.unkin.net_bindacls.yaml b/config/crd/bases/bind.unkin.net_bindacls.yaml new file mode 100644 index 0000000..3cdcb54 --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindacls.yaml @@ -0,0 +1,145 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindacls.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindACL + listKind: BindACLList + plural: bindacls + shortNames: + - bacl + singular: bindacl + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.entries[*] + name: Entries + type: integer + - jsonPath: .status.ready + name: Ready + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BindACL is a named address-match-list referenced by views, zones and + policies for match-clients / allow-query / allow-transfer / allow-update. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: BindACLSpec defines a reusable named address_match_list. + properties: + clusterRef: + description: |- + ClusterRef names the BindCluster whose named.conf this ACL is rendered + into. When empty the ACL is available to every cluster in the namespace. + type: string + entries: + description: |- + Entries are raw BIND address-match-list elements, e.g. "10.0.0.0/8", + "!192.168.1.5", "key transfer-key", "localhost", "any", or the name of + another ACL. + items: + type: string + minItems: 1 + type: array + required: + - entries + type: object + status: + description: BindACLStatus reports observed ACL state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + ready: + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_bindcatalogzones.yaml b/config/crd/bases/bind.unkin.net_bindcatalogzones.yaml new file mode 100644 index 0000000..2f90f1c --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindcatalogzones.yaml @@ -0,0 +1,159 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindcatalogzones.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindCatalogZone + listKind: BindCatalogZoneList + plural: bindcatalogzones + shortNames: + - bcz + singular: bindcatalogzone + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.zoneName + name: Zone + type: string + - jsonPath: .status.memberCount + name: Members + type: integer + - jsonPath: .status.ready + name: Ready + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + description: BindCatalogZone auto-provisions member zones onto cluster secondaries. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BindCatalogZoneSpec defines a BIND9 catalog zone. The primary publishes it + and secondaries consume it, so member zones are provisioned onto every + secondary automatically without per-zone reconfiguration. + properties: + clusterRef: + description: ClusterRef names the owning BindCluster. + type: string + defaultPrimaries: + description: |- + DefaultPrimaries are the addresses member zones point at on secondaries. + Defaults to the cluster primary Service. + items: + type: string + type: array + transferKeyRef: + description: |- + TransferKeyRef names the BindTSIGKey authenticating catalog + member zone + transfers to secondaries. + type: string + zoneName: + description: ZoneName is the catalog zone's own origin, e.g. "catalog.internal". + type: string + required: + - clusterRef + - zoneName + type: object + status: + description: BindCatalogZoneStatus reports observed catalog state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + memberCount: + description: MemberCount is the number of member zones registered + in the catalog. + format: int32 + type: integer + observedGeneration: + format: int64 + type: integer + ready: + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_bindclusters.yaml b/config/crd/bases/bind.unkin.net_bindclusters.yaml new file mode 100644 index 0000000..dad7eb4 --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindclusters.yaml @@ -0,0 +1,1250 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindclusters.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindCluster + listKind: BindClusterList + plural: bindclusters + shortNames: + - bc + singular: bindcluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.mode + name: Mode + type: string + - jsonPath: .spec.replicas + name: Desired + type: integer + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.primaryPod + name: Primary + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: BindCluster is a managed set of BIND9 servers. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: BindClusterSpec defines the desired state of a BIND cluster. + properties: + affinity: + description: Affinity for the BIND pods. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + allowNewZones: + default: true + description: |- + AllowNewZones enables the rndc addzone/delzone control path required for + dynamic zone provisioning. Defaults to true. + type: boolean + catalogZoneRef: + description: |- + CatalogZoneRef names a BindCatalogZone that secondaries consume so member + zones are auto-provisioned without per-zone reconfiguration. + type: string + extraOptions: + description: ExtraOptions are raw named.conf `options { ... }` lines + appended verbatim. + items: + type: string + type: array + forwarders: + description: |- + Forwarders is a list of upstream resolvers used by resolver mode (and any + forward zones that do not specify their own). + items: + type: string + type: array + image: + default: git.unkin.net/unkin/bind9:latest + description: Image is the BIND9 container image. + type: string + imagePullPolicy: + description: ImagePullPolicy for the BIND container. + type: string + mode: + default: authoritative + description: Mode selects the cluster role. + enum: + - authoritative + - resolver + - dynamic + type: string + nodeSelector: + additionalProperties: + type: string + description: NodeSelector for the BIND pods. + type: object + recursion: + description: |- + Recursion overrides the default per-mode recursion setting. When nil, + resolver mode enables recursion and the other modes disable it. + type: boolean + replicas: + default: 3 + description: |- + Replicas is the number of BIND pods. Ordinal-0 is the primary for the + authoritative and dynamic modes. + format: int32 + minimum: 1 + type: integer + resources: + description: Resources for the BIND container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service controls how the cluster is exposed. + properties: + annotations: + additionalProperties: + type: string + description: Annotations added to the client-facing Service (e.g. + PureLB/MetalLB hints). + type: object + loadBalancerIP: + description: LoadBalancerIP requests a specific address when Type + is LoadBalancer. + type: string + type: + description: Type of the client-facing Service. Defaults to ClusterIP. + enum: + - ClusterIP + - LoadBalancer + - NodePort + type: string + type: object + storageClassName: + description: StorageClassName for the per-pod PVC that holds zone + data and journals. + type: string + storageSize: + default: 1Gi + description: StorageSize for the per-pod PVC. Defaults to 1Gi. + type: string + tolerations: + description: Tolerations for the BIND pods. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + required: + - mode + type: object + status: + description: BindClusterStatus reports observed cluster state. + properties: + conditions: + description: Conditions represent the latest available observations. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration is the last reconciled generation. + format: int64 + type: integer + phase: + description: Phase is a coarse lifecycle summary. + type: string + primaryPod: + description: PrimaryPod is the pod that holds authoritative zone data + (ordinal-0). + type: string + primaryService: + description: PrimaryService is the in-cluster DNS name secondaries + transfer from. + type: string + readyReplicas: + description: ReadyReplicas is the number of BIND pods currently ready. + format: int32 + type: integer + replicas: + description: Replicas is the number of BIND pods requested. + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_binddnssecpolicies.yaml b/config/crd/bases/bind.unkin.net_binddnssecpolicies.yaml new file mode 100644 index 0000000..ba2a775 --- /dev/null +++ b/config/crd/bases/bind.unkin.net_binddnssecpolicies.yaml @@ -0,0 +1,219 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: binddnssecpolicies.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindDNSSECPolicy + listKind: BindDNSSECPolicyList + plural: binddnssecpolicies + shortNames: + - bdp + singular: binddnssecpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.algorithm + name: Algorithm + type: string + - jsonPath: .status.zoneCount + name: Zones + type: integer + - jsonPath: .status.ready + name: Ready + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + description: BindDNSSECPolicy is a reusable DNSSEC signing policy. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BindDNSSECPolicySpec mirrors a BIND9 dnssec-policy. Zones referencing it are + signed with inline-signing and automated key management. + properties: + algorithm: + default: ecdsap256sha256 + description: Algorithm for signing. Defaults to ecdsap256sha256. + type: string + clusterRef: + description: ClusterRef names the owning BindCluster. + type: string + csk: + description: CSK, when set, uses a Combined Signing Key instead of + split KSK/ZSK. + properties: + algorithm: + description: Algorithm overrides the policy algorithm for this + key. + type: string + keySize: + description: KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA). + format: int32 + type: integer + lifetime: + description: |- + Lifetime is how long the key is used before rollover, e.g. "P30D" or + "unlimited". Empty means unlimited. + type: string + type: object + extraOptions: + description: ExtraOptions are raw named.conf lines appended inside + the policy block. + items: + type: string + type: array + ksk: + description: KSK is the Key Signing Key configuration (ignored when + CSK is set). + properties: + algorithm: + description: Algorithm overrides the policy algorithm for this + key. + type: string + keySize: + description: KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA). + format: int32 + type: integer + lifetime: + description: |- + Lifetime is how long the key is used before rollover, e.g. "P30D" or + "unlimited". Empty means unlimited. + type: string + type: object + maxZoneTTL: + description: MaxZoneTTL, e.g. "P1D". + type: string + nsec3: + description: NSEC3 enables NSEC3 hashing instead of NSEC. + type: boolean + policyName: + description: |- + PolicyName is the dnssec-policy name in named.conf. Defaults to the object + name. + type: string + signaturesValidity: + description: SignaturesValidity, e.g. "P14D". + type: string + zsk: + description: ZSK is the Zone Signing Key configuration (ignored when + CSK is set). + properties: + algorithm: + description: Algorithm overrides the policy algorithm for this + key. + type: string + keySize: + description: KeySize in bits for RSA algorithms (ignored for ECDSA/EdDSA). + format: int32 + type: integer + lifetime: + description: |- + Lifetime is how long the key is used before rollover, e.g. "P30D" or + "unlimited". Empty means unlimited. + type: string + type: object + required: + - clusterRef + type: object + status: + description: BindDNSSECPolicyStatus reports observed policy state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + ready: + type: boolean + zoneCount: + description: ZoneCount is the number of zones signed with this policy. + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_bindpolicies.yaml b/config/crd/bases/bind.unkin.net_bindpolicies.yaml new file mode 100644 index 0000000..c653031 --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindpolicies.yaml @@ -0,0 +1,203 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindpolicies.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindPolicy + listKind: BindPolicyList + plural: bindpolicies + shortNames: + - bp + singular: bindpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.zoneName + name: Zone + type: string + - jsonPath: .status.ruleCount + name: Rules + type: integer + - jsonPath: .status.ready + name: Ready + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + description: BindPolicy is a Response Policy Zone (RPZ) applied to a cluster. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BindPolicySpec defines a Response Policy Zone (RPZ) — a DNS firewall applied + to a resolver cluster. + properties: + clusterRef: + description: ClusterRef names the owning BindCluster (typically a + resolver). + type: string + order: + default: 100 + description: Order controls this policy's position in the response-policy + clause. + format: int32 + type: integer + primaries: + description: |- + Primaries lets the RPZ zone be transferred from an external feed instead + of being locally populated. + items: + type: string + type: array + rules: + description: Rules are the inline policy triggers. + items: + description: RPZRule is a single response-policy rule. + properties: + action: + default: nxdomain + description: Action taken when the rule matches. + enum: + - nxdomain + - nodata + - passthru + - drop + - tcp-only + - cname + type: string + match: + description: Match is the trigger value, e.g. a domain "bad.example." + or CIDR. + type: string + target: + description: Target is the rewrite target when Action is cname. + type: string + trigger: + default: qname + description: Trigger selects what the Match is compared against. + enum: + - qname + - client-ip + - ip + - nsdname + - nsip + type: string + required: + - match + type: object + type: array + transferKeyRef: + description: TransferKeyRef names the BindTSIGKey used to pull from + Primaries. + type: string + viewRef: + description: ViewRef optionally scopes the policy to a single view. + type: string + zoneName: + description: ZoneName is the RPZ zone origin, e.g. "rpz.internal". + type: string + required: + - clusterRef + - zoneName + type: object + status: + description: BindPolicyStatus reports observed policy state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + ready: + type: boolean + ruleCount: + description: RuleCount is the number of active rules. + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml b/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml new file mode 100644 index 0000000..e51cccc --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindtsigkeys.yaml @@ -0,0 +1,165 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindtsigkeys.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindTSIGKey + listKind: BindTSIGKeyList + plural: bindtsigkeys + shortNames: + - btk + singular: bindtsigkey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.algorithm + name: Algorithm + type: string + - jsonPath: .status.secretName + name: Secret + type: string + - jsonPath: .status.ready + name: Ready + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BindTSIGKey is a TSIG key backing zone transfers, dynamic updates and view + matching. The key material lives in a Kubernetes Secret, never in the CR. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BindTSIGKeySpec defines a TSIG key. If no existing key material is imported, + the operator generates a random key and stores it in a Secret. + properties: + algorithm: + default: hmac-sha256 + description: Algorithm is the HMAC algorithm. Defaults to hmac-sha256. + enum: + - hmac-sha256 + - hmac-sha512 + - hmac-sha384 + - hmac-sha224 + - hmac-sha1 + - hmac-md5 + type: string + importExisting: + description: |- + ImportExisting, when true, means the referenced Secret already contains a + `secret` key and the operator will not generate new material. + type: boolean + keyName: + description: |- + KeyName is the TSIG key name emitted into named.conf. Defaults to the + object name. + type: string + secretName: + description: |- + SecretName is the Secret the key material is written to (or read from when + ImportExisting is set). Defaults to "-tsig". + type: string + type: object + status: + description: BindTSIGKeyStatus reports observed TSIG key state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + keyName: + description: KeyName as used in named.conf. + type: string + observedGeneration: + description: ObservedGeneration is the last reconciled generation. + format: int64 + type: integer + ready: + description: Ready is true once the key Secret exists. + type: boolean + secretName: + description: SecretName holds the generated/managed key material. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_bindviews.yaml b/config/crd/bases/bind.unkin.net_bindviews.yaml new file mode 100644 index 0000000..c607569 --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindviews.yaml @@ -0,0 +1,177 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindviews.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindView + listKind: BindViewList + plural: bindviews + shortNames: + - bv + singular: bindview + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .spec.order + name: Order + type: integer + - jsonPath: .status.zoneCount + name: Zones + type: integer + - jsonPath: .status.ready + name: Ready + type: boolean + name: v1alpha1 + schema: + openAPIV3Schema: + description: BindView is a split-horizon view on a BindCluster. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BindViewSpec defines a split-horizon view. View ordering is significant in + BIND; use Order to control the sequence in named.conf. + properties: + allowQuery: + description: AllowQuery is an address-match-list restricting queries + into this view. + items: + type: string + type: array + clusterRef: + description: ClusterRef names the owning BindCluster. + type: string + extraOptions: + description: ExtraOptions are raw named.conf lines appended inside + the view block. + items: + type: string + type: array + matchClients: + description: |- + MatchClients is an address-match-list (inline entries and/or ACL names) + selecting which clients this view answers. Defaults to "any". + items: + type: string + type: array + matchDestinations: + description: MatchDestinations is an optional destination address-match-list. + items: + type: string + type: array + order: + default: 100 + description: |- + Order controls the position of this view in named.conf (ascending). The + first view whose match-clients matches a query wins. + format: int32 + type: integer + recursion: + description: Recursion overrides the cluster recursion setting for + this view. + type: boolean + required: + - clusterRef + type: object + status: + description: BindViewStatus reports observed view state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + ready: + type: boolean + zoneCount: + description: ZoneCount is the number of zones currently bound to this + view. + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_bindzones.yaml b/config/crd/bases/bind.unkin.net_bindzones.yaml new file mode 100644 index 0000000..4e9a960 --- /dev/null +++ b/config/crd/bases/bind.unkin.net_bindzones.yaml @@ -0,0 +1,252 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindzones.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: BindZone + listKind: BindZoneList + plural: bindzones + shortNames: + - bz + singular: bindzone + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.zoneName + name: Zone + type: string + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .spec.clusterRef + name: Cluster + type: string + - jsonPath: .status.serial + name: Serial + type: integer + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: BindZone is a forward or reverse DNS zone. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: BindZoneSpec defines a DNS zone managed on a BindCluster's + primary. + properties: + allowTransfer: + description: |- + AllowTransfer is an address-match-list (inline entries and/or ACL/key + names) permitted to AXFR/IXFR this zone. + items: + type: string + type: array + catalog: + default: true + description: |- + Catalog, when true, registers this zone as a member of the cluster's + catalog zone so secondaries auto-provision it. + type: boolean + clusterRef: + description: ClusterRef names the owning BindCluster. + type: string + defaultTTL: + default: 3600 + description: DefaultTTL for records that do not set their own TTL. + Defaults to 3600. + format: int32 + type: integer + dnssecPolicyRef: + description: DNSSECPolicyRef names a BindDNSSECPolicy to sign this + zone with. + type: string + dynamicUpdate: + description: |- + DynamicUpdate enables RFC2136 updates for this zone (external-dns style). + When true, UpdateKeyRef must reference a BindTSIGKey. + type: boolean + forwarders: + description: Forwarders lists upstreams for a forward-type zone. + items: + type: string + type: array + primaries: + description: Primaries lists source servers for a secondary/stub-type + zone. + items: + type: string + type: array + records: + description: Records are static record sets seeded into a primary + zone. + items: + description: |- + Record is a single resource record set seeded into a primary zone via + dynamic update (nsupdate). Ongoing changes may also arrive from DNSRecord + objects or external RFC2136 clients. + properties: + name: + default: '@' + description: |- + Name is the owner name, relative to the zone apex or fully qualified. + Use "@" for the apex. + type: string + ttl: + description: TTL for the record set in seconds. Falls back to + the zone default TTL. + format: int32 + type: integer + type: + description: Type is the RR type, e.g. A, AAAA, CNAME, MX, TXT, + SRV, NS, PTR, CAA. + type: string + values: + description: |- + Values are the RDATA entries, e.g. ["10 mail.example.com."] for an MX or + ["192.0.2.1","192.0.2.2"] for an A round-robin. + items: + type: string + minItems: 1 + type: array + required: + - type + - values + type: object + type: array + transferKeyRef: + description: |- + TransferKeyRef names the BindTSIGKey used to authenticate transfers from + Primaries for a secondary zone. + type: string + type: + default: primary + description: Type is the zone type. Defaults to primary. + enum: + - primary + - secondary + - forward + - stub + type: string + updateKeyRef: + description: UpdateKeyRef names the BindTSIGKey permitted to send + dynamic updates. + type: string + viewRef: + description: ViewRef optionally binds this zone to a BindView. + type: string + zoneName: + description: ZoneName is the DNS origin, e.g. "example.com" or "2.0.192.in-addr.arpa". + type: string + required: + - clusterRef + - zoneName + type: object + status: + description: BindZoneStatus reports observed zone state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + phase: + description: Phase is a coarse lifecycle summary (Pending/Ready/Error). + type: string + recordCount: + description: RecordCount is the number of managed record sets applied. + format: int32 + type: integer + serial: + description: Serial is the last observed SOA serial on the primary. + format: int64 + type: integer + signed: + description: Signed reports whether DNSSEC signing is active. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/bind.unkin.net_dnsrecords.yaml b/config/crd/bases/bind.unkin.net_dnsrecords.yaml new file mode 100644 index 0000000..18ddaeb --- /dev/null +++ b/config/crd/bases/bind.unkin.net_dnsrecords.yaml @@ -0,0 +1,165 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: dnsrecords.bind.unkin.net +spec: + group: bind.unkin.net + names: + kind: DNSRecord + listKind: DNSRecordList + plural: dnsrecords + shortNames: + - dnsr + singular: dnsrecord + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.zoneRef + name: Zone + type: string + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: DNSRecord is an individually-managed record set applied to a + BindZone. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + DNSRecordSpec defines a single record set applied to a zone via TSIG dynamic + update (nsupdate) — the external-dns write path expressed as a CRD. + properties: + name: + default: '@' + description: Name is the owner name, relative to the zone apex or + fully qualified. + type: string + ttl: + description: TTL for the record set in seconds. Falls back to the + zone default TTL. + format: int32 + type: integer + type: + description: Type is the RR type, e.g. A, AAAA, CNAME, TXT, SRV, MX. + type: string + values: + description: Values are the RDATA entries. + items: + type: string + minItems: 1 + type: array + zoneRef: + description: |- + ZoneRef names the BindZone this record belongs to. The cluster, view and + update key are derived from the referenced zone. + type: string + required: + - type + - values + - zoneRef + type: object + status: + description: DNSRecordStatus reports observed record state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + fqdn: + description: FQDN is the fully-qualified owner name that was applied. + type: string + observedGeneration: + format: int64 + type: integer + phase: + description: Phase is a coarse lifecycle summary (Pending/Applied/Error). + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 0000000..a80b4ed --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,83 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: bind-operator +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - pods/exec + verbs: + - create + - get +- apiGroups: + - apps + resources: + - statefulsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - bind.unkin.net + resources: + - bindacls + - bindcatalogzones + - bindclusters + - binddnssecpolicies + - bindpolicies + - bindtsigkeys + - bindviews + - bindzones + - dnsrecords + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - bind.unkin.net + resources: + - bindacls/status + - bindcatalogzones/status + - bindclusters/status + - binddnssecpolicies/status + - bindpolicies/status + - bindtsigkeys/status + - bindviews/status + - bindzones/status + - dnsrecords/status + verbs: + - get + - patch + - update diff --git a/config/samples/00-tsigkeys.yaml b/config/samples/00-tsigkeys.yaml new file mode 100644 index 0000000..7322d6a --- /dev/null +++ b/config/samples/00-tsigkeys.yaml @@ -0,0 +1,21 @@ +--- +# TSIG key used to authenticate zone transfers between primary and secondaries +# (and catalog zone transfers). The operator generates the material into a +# Secret named -tsig; the key never appears in the CR. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindTSIGKey +metadata: + name: transfer-key + namespace: bind-auth +spec: + algorithm: hmac-sha256 +--- +# TSIG key permitting external-dns (and DNSRecord objects) to send RFC2136 +# dynamic updates to the dynamic cluster's primary. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindTSIGKey +metadata: + name: externaldns-key + namespace: bind-externaldns +spec: + algorithm: hmac-sha256 diff --git a/config/samples/01-authoritative.yaml b/config/samples/01-authoritative.yaml new file mode 100644 index 0000000..1ba73ca --- /dev/null +++ b/config/samples/01-authoritative.yaml @@ -0,0 +1,102 @@ +--- +# Authoritative masters role (replaces 3x Puppet authoritative servers). +# Ordinal-0 is the primary holding zone data; the other two replicate via the +# catalog zone + AXFR/IXFR. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindCluster +metadata: + name: auth + namespace: bind-auth +spec: + mode: authoritative + replicas: 3 + storageSize: 2Gi + service: + type: LoadBalancer + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi +--- +apiVersion: bind.unkin.net/v1alpha1 +kind: BindACL +metadata: + name: internal-nets + namespace: bind-auth +spec: + clusterRef: auth + entries: + - 10.0.0.0/8 + - 192.168.0.0/16 +--- +# Catalog zone: new BindZones are auto-provisioned onto the secondaries. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindCatalogZone +metadata: + name: auth-catalog + namespace: bind-auth +spec: + clusterRef: auth + zoneName: catalog.internal + transferKeyRef: transfer-key +--- +apiVersion: bind.unkin.net/v1alpha1 +kind: BindDNSSECPolicy +metadata: + name: standard + namespace: bind-auth +spec: + clusterRef: auth + algorithm: ecdsap256sha256 + nsec3: true + csk: + lifetime: unlimited +--- +# Forward zone (signed) with a couple of seeded records. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindZone +metadata: + name: example-internal + namespace: bind-auth +spec: + clusterRef: auth + zoneName: internal.example.com + type: primary + defaultTTL: 3600 + dnssecPolicyRef: standard + allowTransfer: + - key transfer-key + updateKeyRef: transfer-key + dynamicUpdate: true + records: + - name: "@" + type: NS + values: ["ns1.internal.example.com."] + - name: ns1 + type: A + values: ["10.0.0.53"] + - name: www + type: A + values: ["10.0.1.10", "10.0.1.11"] +--- +# Reverse zone for 10.0.0.0/16. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindZone +metadata: + name: reverse-10-0 + namespace: bind-auth +spec: + clusterRef: auth + zoneName: 0.10.in-addr.arpa + type: primary + updateKeyRef: transfer-key + dynamicUpdate: true + allowTransfer: + - key transfer-key + records: + - name: "53.0" + type: PTR + values: ["ns1.internal.example.com."] diff --git a/config/samples/02-resolver.yaml b/config/samples/02-resolver.yaml new file mode 100644 index 0000000..6d7ea57 --- /dev/null +++ b/config/samples/02-resolver.yaml @@ -0,0 +1,50 @@ +--- +# Recursive resolvers role (replaces 3x Puppet only-resolver servers). +# All three pods are identical recursive servers; no zone replication. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindCluster +metadata: + name: resolver + namespace: bind-resolver +spec: + mode: resolver + replicas: 3 + service: + type: LoadBalancer + forwarders: + - 1.1.1.1 + - 9.9.9.9 +--- +# Conditional forwarding of an internal zone to the authoritative cluster. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindZone +metadata: + name: forward-internal + namespace: bind-resolver +spec: + clusterRef: resolver + zoneName: internal.example.com + type: forward + catalog: false + forwarders: + - 10.0.0.53 +--- +# DNS firewall (RPZ) blocklist applied to the resolvers. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindPolicy +metadata: + name: blocklist + namespace: bind-resolver +spec: + clusterRef: resolver + zoneName: rpz.internal + order: 10 + transferKeyRef: transfer-key + rules: + - trigger: qname + match: malware.example. + action: nxdomain + - trigger: qname + match: tracker.example. + action: cname + target: blocked.internal.example.com diff --git a/config/samples/03-external-dns.yaml b/config/samples/03-external-dns.yaml new file mode 100644 index 0000000..990acc5 --- /dev/null +++ b/config/samples/03-external-dns.yaml @@ -0,0 +1,43 @@ +--- +# external-dns role (replaces 3x Puppet external-dns servers). The primary +# accepts RFC2136 TSIG updates from external-dns; secondaries replicate. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindCluster +metadata: + name: externaldns + namespace: bind-externaldns +spec: + mode: dynamic + replicas: 3 + service: + type: LoadBalancer +--- +# Public zone that external-dns writes into via nsupdate/TSIG. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindZone +metadata: + name: example-com + namespace: bind-externaldns +spec: + clusterRef: externaldns + zoneName: example.com + type: primary + dynamicUpdate: true + updateKeyRef: externaldns-key + allowTransfer: + - key externaldns-key +--- +# A record managed as a CRD (external-dns-style) instead of via the RFC2136 +# controller — same write path (TSIG nsupdate to the primary). +apiVersion: bind.unkin.net/v1alpha1 +kind: DNSRecord +metadata: + name: www-example-com + namespace: bind-externaldns +spec: + zoneRef: example-com + name: www + type: A + ttl: 300 + values: + - 203.0.113.10 diff --git a/config/samples/04-view.yaml b/config/samples/04-view.yaml new file mode 100644 index 0000000..7f19556 --- /dev/null +++ b/config/samples/04-view.yaml @@ -0,0 +1,26 @@ +--- +# Split-horizon example: an internal view answering RFC1918 clients and a +# default external view. Bind a zone to a view via BindZone.spec.viewRef. +apiVersion: bind.unkin.net/v1alpha1 +kind: BindView +metadata: + name: internal + namespace: bind-auth +spec: + clusterRef: auth + order: 10 + matchClients: + - internal-nets + recursion: false +--- +apiVersion: bind.unkin.net/v1alpha1 +kind: BindView +metadata: + name: external + namespace: bind-auth +spec: + clusterRef: auth + order: 100 + matchClients: + - any + recursion: false diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f17bf14 --- /dev/null +++ b/go.mod @@ -0,0 +1,71 @@ +module git.unkin.net/unkin/bind-operator + +go 1.25 + +require ( + k8s.io/api v0.34.4 + k8s.io/apimachinery v0.34.4 + k8s.io/client-go v0.34.4 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a29b12 --- /dev/null +++ b/go.sum @@ -0,0 +1,206 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= +github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0= +k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc= +k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= +k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/kind/manifests/namespace.yaml b/hack/kind/manifests/namespace.yaml new file mode 100644 index 0000000..3dce9e6 --- /dev/null +++ b/hack/kind/manifests/namespace.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: bind-operator-system diff --git a/hack/kind/manifests/operator-deployment.yaml b/hack/kind/manifests/operator-deployment.yaml new file mode 100644 index 0000000..c48c40f --- /dev/null +++ b/hack/kind/manifests/operator-deployment.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bind-operator + namespace: bind-operator-system + labels: + app: bind-operator +spec: + replicas: 1 + selector: + matchLabels: + app: bind-operator + template: + metadata: + labels: + app: bind-operator + spec: + serviceAccountName: bind-operator + containers: + - name: operator + image: bind-operator:dev + imagePullPolicy: Never + args: + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:8081 + - --leader-elect + ports: + - containerPort: 8080 + name: metrics + - containerPort: 8081 + name: health + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/hack/kind/manifests/rbac.yaml b/hack/kind/manifests/rbac.yaml new file mode 100644 index 0000000..35ee5a2 --- /dev/null +++ b/hack/kind/manifests/rbac.yaml @@ -0,0 +1,46 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: bind-operator + namespace: bind-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: bind-operator +rules: + - apiGroups: ["bind.unkin.net"] + resources: ["*"] + verbs: ["*"] + - apiGroups: [""] + resources: ["services", "configmaps", "secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create", "get"] + - apiGroups: ["apps"] + resources: ["statefulsets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: bind-operator +subjects: + - kind: ServiceAccount + name: bind-operator + namespace: bind-operator-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: bind-operator diff --git a/internal/bind/exec.go b/internal/bind/exec.go new file mode 100644 index 0000000..3b6117e --- /dev/null +++ b/internal/bind/exec.go @@ -0,0 +1,66 @@ +// Package bind contains helpers for driving BIND9 pods: executing rndc and +// nsupdate over the Kubernetes exec subresource, and rendering named.conf. +package bind + +import ( + "bytes" + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" +) + +// ContainerName is the BIND container name within each pod. +const ContainerName = "bind" + +// Executor runs commands inside BIND pods via the exec subresource. +type Executor struct { + config *rest.Config + clientset kubernetes.Interface +} + +// NewExecutor builds an Executor from a controller-runtime rest config. +func NewExecutor(cfg *rest.Config) (*Executor, error) { + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("build clientset: %w", err) + } + return &Executor{config: cfg, clientset: cs}, nil +} + +// Exec runs command in the BIND container of pod, optionally feeding stdin, and +// returns stdout. A non-zero exit or transport error yields an error that +// includes stderr. +func (e *Executor) Exec(ctx context.Context, namespace, pod string, command []string, stdin string) (string, error) { + req := e.clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod). + Namespace(namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: ContainerName, + Command: command, + Stdin: stdin != "", + Stdout: true, + Stderr: true, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(e.config, "POST", req.URL()) + if err != nil { + return "", fmt.Errorf("spdy executor: %w", err) + } + + var stdout, stderr bytes.Buffer + opts := remotecommand.StreamOptions{Stdout: &stdout, Stderr: &stderr} + if stdin != "" { + opts.Stdin = bytes.NewBufferString(stdin) + } + if err := exec.StreamWithContext(ctx, opts); err != nil { + return stdout.String(), fmt.Errorf("exec %v: %w (stderr: %s)", command, err, stderr.String()) + } + return stdout.String(), nil +} diff --git a/internal/bind/hash.go b/internal/bind/hash.go new file mode 100644 index 0000000..e689050 --- /dev/null +++ b/internal/bind/hash.go @@ -0,0 +1,29 @@ +package bind + +import ( + "crypto/sha1" + "encoding/hex" + "strings" +) + +// catalogHash returns the unique member label for a catalog zone entry: the +// hex-encoded SHA-1 digest of the member zone name in DNS wire format, per the +// BIND catalog-zone schema (RFC 9432). +func catalogHash(zone string) string { + sum := sha1.Sum(wireName(zone)) + return hex.EncodeToString(sum[:]) +} + +// wireName encodes a domain name into uncompressed DNS wire format: each label +// length-prefixed, terminated by a zero-length root label. Names are lowercased. +func wireName(name string) []byte { + name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") + var out []byte + if name != "" { + for _, label := range strings.Split(name, ".") { + out = append(out, byte(len(label))) + out = append(out, []byte(label)...) + } + } + return append(out, 0) +} diff --git a/internal/bind/keygen.go b/internal/bind/keygen.go new file mode 100644 index 0000000..cbaa817 --- /dev/null +++ b/internal/bind/keygen.go @@ -0,0 +1,34 @@ +package bind + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// GenerateSecret returns a base64-encoded cryptographically-random key of n +// bytes, suitable for a TSIG or rndc HMAC secret. +func GenerateSecret(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + return base64.StdEncoding.EncodeToString(buf), nil +} + +// KeyClause renders a named.conf `key` block for inclusion. +func KeyClause(name, algorithm, secret string) string { + return fmt.Sprintf("key \"%s\" {\n algorithm %s;\n secret \"%s\";\n};\n", name, algorithm, secret) +} + +// SecretBytesForAlgorithm returns a reasonable key length for a TSIG algorithm. +func SecretBytesForAlgorithm(algorithm string) int { + switch algorithm { + case "hmac-sha512", "hmac-sha384": + return 64 + case "hmac-sha256": + return 32 + default: + return 32 + } +} diff --git a/internal/bind/nsupdate.go b/internal/bind/nsupdate.go new file mode 100644 index 0000000..22f8769 --- /dev/null +++ b/internal/bind/nsupdate.go @@ -0,0 +1,60 @@ +package bind + +import ( + "context" + "fmt" + "strings" +) + +// TSIGCreds carries the material needed to authenticate a dynamic update. +type TSIGCreds struct { + Name string // TSIG key name + Algorithm string // e.g. hmac-sha256 + Secret string // base64-encoded key +} + +// RecordUpdate describes a desired record set to apply to a zone. +type RecordUpdate struct { + FQDN string // fully-qualified owner name, trailing dot recommended + Type string // RR type + TTL int32 // record TTL + Values []string // RDATA entries + Delete bool // when true, delete the RRset instead of replacing it +} + +// NSUpdate applies a set of record changes to zone by executing nsupdate on the +// primary pod, targeting the local server and authenticating with creds. All +// changes are sent in a single atomic transaction. +func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error { + var b strings.Builder + b.WriteString("server 127.0.0.1\n") + b.WriteString(fmt.Sprintf("zone %s\n", dot(zone))) + for _, u := range updates { + // Replace semantics: clear the RRset first, then add the desired values. + b.WriteString(fmt.Sprintf("update delete %s %s\n", dot(u.FQDN), u.Type)) + if u.Delete { + continue + } + for _, v := range u.Values { + b.WriteString(fmt.Sprintf("update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v)) + } + } + b.WriteString("send\n") + + cmd := []string{"nsupdate", "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)} + if out, err := e.Exec(ctx, namespace, pod, cmd, b.String()); err != nil { + return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out) + } + return nil +} + +// dot ensures a name is fully qualified with a trailing dot. +func dot(name string) string { + if name == "" || name == "@" { + return "@" + } + if strings.HasSuffix(name, ".") { + return name + } + return name + "." +} diff --git a/internal/bind/render.go b/internal/bind/render.go new file mode 100644 index 0000000..89d60fa --- /dev/null +++ b/internal/bind/render.go @@ -0,0 +1,289 @@ +package bind + +import ( + "fmt" + "sort" + "strings" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +// RenderInput aggregates everything needed to render a cluster's named.conf. +type RenderInput struct { + Cluster *bindv1alpha1.BindCluster + ACLs []bindv1alpha1.BindACL + Views []bindv1alpha1.BindView + Policies []bindv1alpha1.BindPolicy + DNSSECPolicies []bindv1alpha1.BindDNSSECPolicy + Catalog *bindv1alpha1.BindCatalogZone + // PrimaryAddress is the in-cluster address secondaries transfer from. + PrimaryAddress string +} + +// DataDir is where BIND keeps zone databases and journals (backed by the PVC). +const DataDir = "/var/lib/named" + +// RenderNamedConf returns the primary and secondary named.conf contents for a +// cluster. Both variants are shipped in the ConfigMap; the entrypoint selects +// one based on the pod ordinal. +func RenderNamedConf(in RenderInput) (primary string, secondary string) { + return render(in, true), render(in, false) +} + +func render(in RenderInput, isPrimary bool) string { + c := in.Cluster + var b strings.Builder + + b.WriteString("// Managed by bind-operator. Do not edit.\n") + b.WriteString(`include "/etc/bind/keys/keys.conf";` + "\n\n") + + // Named ACLs (global scope). + acls := append([]bindv1alpha1.BindACL(nil), in.ACLs...) + sort.Slice(acls, func(i, j int) bool { return acls[i].Name < acls[j].Name }) + for _, a := range acls { + b.WriteString(fmt.Sprintf("acl \"%s\" { %s };\n", a.Name, matchList(a.Spec.Entries))) + } + if len(acls) > 0 { + b.WriteString("\n") + } + + // DNSSEC policies (must precede zones that reference them). + for _, p := range in.DNSSECPolicies { + b.WriteString(renderDNSSECPolicy(p)) + } + + // options. + b.WriteString("options {\n") + b.WriteString(fmt.Sprintf(" directory \"%s\";\n", DataDir)) + b.WriteString(" listen-on port 53 { any; };\n") + b.WriteString(" listen-on-v6 port 53 { any; };\n") + b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(recursionFor(c)))) + if len(c.Spec.Forwarders) > 0 { + b.WriteString(fmt.Sprintf(" forwarders { %s };\n", terminate(c.Spec.Forwarders))) + } + if allowNewZones(c) { + b.WriteString(" allow-new-zones yes;\n") + } + b.WriteString(" dnssec-validation auto;\n") + for _, o := range c.Spec.ExtraOptions { + b.WriteString(" " + strings.TrimRight(o, ";") + ";\n") + } + // When there are no views, response-policy and catalog-zones live in options. + if len(in.Views) == 0 { + b.WriteString(responsePolicyClause(in.Policies, " ")) + b.WriteString(catalogZonesClause(in, isPrimary, " ")) + } + b.WriteString("};\n\n") + + // controls (rndc). + b.WriteString("controls {\n") + b.WriteString(" inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { \"rndc-key\"; };\n") + b.WriteString("};\n\n") + + // Views, if any. + views := append([]bindv1alpha1.BindView(nil), in.Views...) + sort.Slice(views, func(i, j int) bool { return views[i].Spec.Order < views[j].Spec.Order }) + for _, v := range views { + b.WriteString(renderView(v, in, isPrimary)) + } + + // Catalog zone declaration lives at top level when there are no views. + if in.Catalog != nil && len(in.Views) == 0 { + b.WriteString(renderCatalogZoneDecl(in, isPrimary, "")) + } + + return b.String() +} + +func renderView(v bindv1alpha1.BindView, in RenderInput, isPrimary bool) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("view \"%s\" {\n", v.Name)) + mc := v.Spec.MatchClients + if len(mc) == 0 { + mc = []string{"any"} + } + b.WriteString(fmt.Sprintf(" match-clients { %s };\n", matchList(mc))) + if len(v.Spec.MatchDestinations) > 0 { + b.WriteString(fmt.Sprintf(" match-destinations { %s };\n", matchList(v.Spec.MatchDestinations))) + } + rec := recursionFor(in.Cluster) + if v.Spec.Recursion != nil { + rec = *v.Spec.Recursion + } + b.WriteString(fmt.Sprintf(" recursion %s;\n", yesno(rec))) + if len(v.Spec.AllowQuery) > 0 { + b.WriteString(fmt.Sprintf(" allow-query { %s };\n", matchList(v.Spec.AllowQuery))) + } + for _, o := range v.Spec.ExtraOptions { + b.WriteString(" " + strings.TrimRight(o, ";") + ";\n") + } + // Policies and catalog scoped to this view. + viewPolicies := filterPoliciesForView(in.Policies, v.Name) + b.WriteString(responsePolicyClause(viewPolicies, " ")) + b.WriteString(catalogZonesClause(in, isPrimary, " ")) + if in.Catalog != nil { + b.WriteString(renderCatalogZoneDecl(in, isPrimary, " ")) + } + b.WriteString("};\n\n") + return b.String() +} + +func renderDNSSECPolicy(p bindv1alpha1.BindDNSSECPolicy) string { + name := p.Spec.PolicyName + if name == "" { + name = p.Name + } + var b strings.Builder + b.WriteString(fmt.Sprintf("dnssec-policy \"%s\" {\n", name)) + if p.Spec.NSEC3 { + b.WriteString(" nsec3param;\n") + } + if p.Spec.MaxZoneTTL != "" { + b.WriteString(fmt.Sprintf(" max-zone-ttl %s;\n", p.Spec.MaxZoneTTL)) + } + if p.Spec.SignaturesValidity != "" { + b.WriteString(fmt.Sprintf(" signatures-validity %s;\n", p.Spec.SignaturesValidity)) + } + alg := p.Spec.Algorithm + if alg == "" { + alg = "ecdsap256sha256" + } + if p.Spec.CSK != nil { + b.WriteString(" keys {\n") + b.WriteString(" csk " + keyLine(p.Spec.CSK, alg) + ";\n") + b.WriteString(" };\n") + } else { + b.WriteString(" keys {\n") + if p.Spec.KSK != nil { + b.WriteString(" ksk " + keyLine(p.Spec.KSK, alg) + ";\n") + } + if p.Spec.ZSK != nil { + b.WriteString(" zsk " + keyLine(p.Spec.ZSK, alg) + ";\n") + } + b.WriteString(" };\n") + } + for _, o := range p.Spec.ExtraOptions { + b.WriteString(" " + strings.TrimRight(o, ";") + ";\n") + } + b.WriteString("};\n\n") + return b.String() +} + +func keyLine(k *bindv1alpha1.DNSSECKey, defaultAlg string) string { + lifetime := k.Lifetime + if lifetime == "" { + lifetime = "unlimited" + } + alg := k.Algorithm + if alg == "" { + alg = defaultAlg + } + if k.KeySize > 0 { + return fmt.Sprintf("lifetime %s algorithm %s %d", lifetime, alg, k.KeySize) + } + return fmt.Sprintf("lifetime %s algorithm %s", lifetime, alg) +} + +func responsePolicyClause(policies []bindv1alpha1.BindPolicy, indent string) string { + if len(policies) == 0 { + return "" + } + sorted := append([]bindv1alpha1.BindPolicy(nil), policies...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Spec.Order < sorted[j].Spec.Order }) + var b strings.Builder + b.WriteString(indent + "response-policy {\n") + for _, p := range sorted { + b.WriteString(fmt.Sprintf("%s zone \"%s\";\n", indent, p.Spec.ZoneName)) + } + b.WriteString(indent + "};\n") + return b.String() +} + +func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string { + // Only secondaries consume the catalog to auto-provision member zones. + if in.Catalog == nil || isPrimary { + return "" + } + primaries := in.Catalog.Spec.DefaultPrimaries + if len(primaries) == 0 && in.PrimaryAddress != "" { + primaries = []string{in.PrimaryAddress} + } + var b strings.Builder + b.WriteString(indent + "catalog-zones {\n") + b.WriteString(fmt.Sprintf("%s zone \"%s\" default-primaries { %s };\n", indent, in.Catalog.Spec.ZoneName, terminate(primaries))) + b.WriteString(indent + "};\n") + return b.String() +} + +// renderCatalogZoneDecl declares the catalog zone as a secondary on consumer +// pods. The primary hosts the catalog zone dynamically (created by the +// BindCatalogZone controller via rndc addzone), so nothing is emitted here for +// the primary. +func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string { + if isPrimary { + return "" + } + cat := in.Catalog + file := CatalogFilePath(cat.Spec.ZoneName) + primaries := cat.Spec.DefaultPrimaries + if len(primaries) == 0 && in.PrimaryAddress != "" { + primaries = []string{in.PrimaryAddress} + } + var b strings.Builder + b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, cat.Spec.ZoneName)) + b.WriteString(indent + " type secondary;\n") + b.WriteString(fmt.Sprintf("%s file \"%s\";\n", indent, file)) + b.WriteString(fmt.Sprintf("%s primaries { %s };\n", indent, terminate(primaries))) + b.WriteString(indent + "};\n\n") + return b.String() +} + +func filterPoliciesForView(policies []bindv1alpha1.BindPolicy, view string) []bindv1alpha1.BindPolicy { + var out []bindv1alpha1.BindPolicy + for _, p := range policies { + if p.Spec.ViewRef == view || p.Spec.ViewRef == "" { + out = append(out, p) + } + } + return out +} + +// matchList renders address-match-list elements, each terminated with a +// semicolon: `10.0.0.0/8; key foo;`. +func matchList(entries []string) string { + return terminate(entries) +} + +// terminate joins elements each followed by "; ". +func terminate(entries []string) string { + var parts []string + for _, e := range entries { + e = strings.TrimSpace(strings.TrimRight(e, ";")) + if e == "" { + continue + } + parts = append(parts, e+";") + } + return strings.Join(parts, " ") +} + +func yesno(b bool) string { + if b { + return "yes" + } + return "no" +} + +func recursionFor(c *bindv1alpha1.BindCluster) bool { + if c.Spec.Recursion != nil { + return *c.Spec.Recursion + } + return c.Spec.Mode == bindv1alpha1.ModeResolver +} + +func allowNewZones(c *bindv1alpha1.BindCluster) bool { + if c.Spec.AllowNewZones != nil { + return *c.Spec.AllowNewZones + } + return true +} diff --git a/internal/bind/render_test.go b/internal/bind/render_test.go new file mode 100644 index 0000000..30faf78 --- /dev/null +++ b/internal/bind/render_test.go @@ -0,0 +1,80 @@ +package bind + +import ( + "strings" + "testing" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newCluster(mode bindv1alpha1.BindMode) *bindv1alpha1.BindCluster { + return &bindv1alpha1.BindCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "dns"}, + Spec: bindv1alpha1.BindClusterSpec{Mode: mode, Replicas: 3}, + } +} + +func TestRenderResolverEnablesRecursion(t *testing.T) { + primary, secondary := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeResolver)}) + if !strings.Contains(primary, "recursion yes;") { + t.Fatalf("resolver primary should enable recursion:\n%s", primary) + } + if !strings.Contains(secondary, "recursion yes;") { + t.Fatalf("resolver secondary should enable recursion") + } +} + +func TestRenderAuthoritativeDisablesRecursion(t *testing.T) { + primary, _ := RenderNamedConf(RenderInput{Cluster: newCluster(bindv1alpha1.ModeAuthoritative)}) + if !strings.Contains(primary, "recursion no;") { + t.Fatalf("authoritative should disable recursion:\n%s", primary) + } + if !strings.Contains(primary, "allow-new-zones yes;") { + t.Fatalf("authoritative should allow new zones for dynamic provisioning") + } +} + +func TestRenderCatalogOnSecondaryOnly(t *testing.T) { + in := RenderInput{ + Cluster: newCluster(bindv1alpha1.ModeAuthoritative), + Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal", DefaultPrimaries: []string{"10.0.0.1"}}}, + PrimaryAddress: "auth-0.auth-headless.dns.svc.cluster.local", + } + primary, secondary := RenderNamedConf(in) + if strings.Contains(primary, "catalog-zones") { + t.Fatalf("primary must not consume the catalog it publishes:\n%s", primary) + } + if !strings.Contains(secondary, "catalog-zones") { + t.Fatalf("secondary must consume the catalog zone:\n%s", secondary) + } + if !strings.Contains(secondary, "type secondary;") { + t.Fatalf("secondary must declare the catalog zone as a secondary") + } +} + +func TestRenderACL(t *testing.T) { + in := RenderInput{ + Cluster: newCluster(bindv1alpha1.ModeAuthoritative), + ACLs: []bindv1alpha1.BindACL{{ + ObjectMeta: metav1.ObjectMeta{Name: "internal"}, + Spec: bindv1alpha1.BindACLSpec{Entries: []string{"10.0.0.0/8", "192.168.0.0/16"}}, + }}, + } + primary, _ := RenderNamedConf(in) + if !strings.Contains(primary, `acl "internal" { 10.0.0.0/8; 192.168.0.0/16; };`) { + t.Fatalf("ACL not rendered correctly:\n%s", primary) + } +} + +func TestCatalogHashStable(t *testing.T) { + // SHA-1 of the wire format of "example.com" is well-defined and stable. + h1 := catalogHash("example.com") + h2 := catalogHash("example.com.") + if h1 != h2 { + t.Fatalf("trailing dot should not change hash: %s vs %s", h1, h2) + } + if len(h1) != 40 { + t.Fatalf("expected 40-char hex sha1, got %d: %s", len(h1), h1) + } +} diff --git a/internal/bind/rndc.go b/internal/bind/rndc.go new file mode 100644 index 0000000..35f6336 --- /dev/null +++ b/internal/bind/rndc.go @@ -0,0 +1,89 @@ +package bind + +import ( + "context" + "fmt" + "strings" +) + +// RndcConfPath is the operator-managed rndc client config mounted in each pod. +const RndcConfPath = "/etc/bind/rndc.conf" + +// Rndc runs `rndc ` on a pod and returns its output. +func (e *Executor) Rndc(ctx context.Context, namespace, pod string, args ...string) (string, error) { + base := []string{"rndc", "-c", RndcConfPath} + return e.Exec(ctx, namespace, pod, append(base, args...), "") +} + +// Reconfig reloads named.conf and any newly added/removed zones without a full +// restart. +func (e *Executor) Reconfig(ctx context.Context, namespace, pod string) error { + _, err := e.Rndc(ctx, namespace, pod, "reconfig") + return err +} + +// AddZone provisions a zone at runtime via `rndc addzone`. config is the inner +// zone clause, e.g. `{ type primary; file "db.example"; allow-update { key k; }; };`. +func (e *Executor) AddZone(ctx context.Context, namespace, pod, zone, view, config string) error { + args := []string{"addzone", zone} + if view != "" { + args = append(args, "in", view) + } + args = append(args, config) + out, err := e.Rndc(ctx, namespace, pod, args...) + if err != nil { + // addzone fails if the zone already exists; fall back to modzone so the + // operation is idempotent. + if strings.Contains(err.Error(), "already exists") || strings.Contains(out, "already exists") { + return e.ModZone(ctx, namespace, pod, zone, view, config) + } + return err + } + return nil +} + +// ModZone updates an existing runtime-added zone's configuration. +func (e *Executor) ModZone(ctx context.Context, namespace, pod, zone, view, config string) error { + args := []string{"modzone", zone} + if view != "" { + args = append(args, "in", view) + } + args = append(args, config) + _, err := e.Rndc(ctx, namespace, pod, args...) + return err +} + +// DelZone removes a runtime-added zone. A missing zone is treated as success. +func (e *Executor) DelZone(ctx context.Context, namespace, pod, zone, view string) error { + args := []string{"delzone", zone} + if view != "" { + args = append(args, "in", view) + } + out, err := e.Rndc(ctx, namespace, pod, args...) + if err != nil && (strings.Contains(err.Error(), "not found") || strings.Contains(out, "not found")) { + return nil + } + return err +} + +// ZoneSerial returns the current SOA serial for a zone via `rndc zonestatus`. +func (e *Executor) ZoneSerial(ctx context.Context, namespace, pod, zone, view string) (int64, error) { + args := []string{"zonestatus", zone} + if view != "" { + args = append(args, "in", view) + } + out, err := e.Rndc(ctx, namespace, pod, args...) + if err != nil { + return 0, err + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "serial:") { + var serial int64 + if _, err := fmt.Sscanf(line, "serial: %d", &serial); err == nil { + return serial, nil + } + } + } + return 0, nil +} diff --git a/internal/bind/seed.go b/internal/bind/seed.go new file mode 100644 index 0000000..1c9aed9 --- /dev/null +++ b/internal/bind/seed.go @@ -0,0 +1,74 @@ +package bind + +import ( + "context" + "fmt" + "strings" +) + +// ZoneFilePath returns the on-pod path of a zone database file. +func ZoneFilePath(zone string) string { + return fmt.Sprintf("%s/zones/db.%s", DataDir, strings.TrimSuffix(zone, ".")) +} + +// CatalogFilePath returns the on-pod path of a catalog zone database file. +func CatalogFilePath(zone string) string { + return fmt.Sprintf("%s/catalog/db.%s", DataDir, strings.TrimSuffix(zone, ".")) +} + +// ZoneExists reports whether a zone is currently loaded on the pod. +func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view string) bool { + args := []string{"zonestatus", zone} + if view != "" { + args = append(args, "in", view) + } + _, err := e.Rndc(ctx, namespace, pod, args...) + return err == nil +} + +// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS) to path, +// creating parent directories. It is only safe to call when creating a zone, as +// it overwrites any existing file. +func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryNS string, serial int64) error { + origin := dot(zone) + if primaryNS == "" { + primaryNS = "ns1." + origin + } + content := fmt.Sprintf(`$TTL 3600 +@ IN SOA %s hostmaster.%s ( + %d ; serial + 3600 ; refresh + 900 ; retry + 1209600 ; expire + 300 ) ; minimum +@ IN NS %s +`, dot(primaryNS), origin, serial, dot(primaryNS)) + + cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)} + if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil { + return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out) + } + return nil +} + +// AddCatalogMember registers a member zone in a catalog zone by adding the +// catalog PTR record, so secondaries auto-provision it. +func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error { + hash := catalogHash(memberZone) + owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone)) + updates := []RecordUpdate{{ + FQDN: owner, + Type: "PTR", + TTL: 3600, + Values: []string{dot(memberZone)}, + }} + return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates) +} + +// RemoveCatalogMember deregisters a member zone from a catalog zone. +func (e *Executor) RemoveCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error { + hash := catalogHash(memberZone) + owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone)) + updates := []RecordUpdate{{FQDN: owner, Type: "PTR", Delete: true}} + return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates) +} diff --git a/internal/controller/bindacl_controller.go b/internal/controller/bindacl_controller.go new file mode 100644 index 0000000..7c75963 --- /dev/null +++ b/internal/controller/bindacl_controller.go @@ -0,0 +1,41 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +// BindACLReconciler validates a BindACL and reports readiness. The rendered ACL +// is emitted into named.conf by the BindCluster controller, which watches ACLs. +type BindACLReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls/status,verbs=get;update;patch + +func (r *BindACLReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var acl bindv1alpha1.BindACL + if err := r.Get(ctx, req.NamespacedName, &acl); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + acl.Status.Ready = len(acl.Spec.Entries) > 0 + acl.Status.ObservedGeneration = acl.Generation + setReady(&acl.Status.Conditions, acl.Generation, acl.Status.Ready, "Validated", "ACL rendered into named.conf") + if err := r.Status().Update(ctx, &acl); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *BindACLReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindACL{}). + Complete(r) +} diff --git a/internal/controller/bindcatalogzone_controller.go b/internal/controller/bindcatalogzone_controller.go new file mode 100644 index 0000000..a7d83a6 --- /dev/null +++ b/internal/controller/bindcatalogzone_controller.go @@ -0,0 +1,111 @@ +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// BindCatalogZoneReconciler creates and maintains the catalog zone on a cluster +// primary so secondaries auto-provision member zones. +type BindCatalogZoneReconciler struct { + client.Client + Scheme *runtime.Scheme + Exec *bind.Executor +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones;bindtsigkeys,verbs=get;list;watch + +func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var catalog bindv1alpha1.BindCatalogZone + if err := r.Get(ctx, req.NamespacedName, &catalog); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + cluster, err := getCluster(ctx, r.Client, catalog.Namespace, catalog.Spec.ClusterRef) + if err != nil { + return r.fail(ctx, &catalog, "ClusterMissing", err.Error()) + } + primaryPod := primaryPodName(cluster.Name) + + if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil { + return r.fail(ctx, &catalog, "PrimaryNotReady", "waiting for cluster primary") + } + + creds, err := resolveTSIG(ctx, r.Client, catalog.Namespace, catalog.Spec.TransferKeyRef) + if err != nil { + return r.fail(ctx, &catalog, "NoTransferKey", err.Error()) + } + + // Ensure the catalog zone exists on the primary. + if !r.Exec.ZoneExists(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "") { + if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), "", 1); err != nil { + return r.fail(ctx, &catalog, "SeedFailed", err.Error()) + } + } + zoneConfig := fmt.Sprintf("{ type primary; file \"%s\"; allow-transfer { key \"%s\"; }; allow-update { key \"%s\"; }; };", + bind.CatalogFilePath(catalog.Spec.ZoneName), catalog.Spec.TransferKeyRef, catalog.Spec.TransferKeyRef) + if err := r.Exec.AddZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "", zoneConfig); err != nil { + return r.fail(ctx, &catalog, "AddZoneFailed", err.Error()) + } + + // Catalog zones must advertise their schema version (RFC 9432: "2"). + versionUpdate := bind.RecordUpdate{ + FQDN: "version." + catalog.Spec.ZoneName + ".", + Type: "TXT", + TTL: 3600, + Values: []string{"\"2\""}, + } + if err := r.Exec.NSUpdate(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, creds, []bind.RecordUpdate{versionUpdate}); err != nil { + return r.fail(ctx, &catalog, "VersionUpdateFailed", err.Error()) + } + + // Count member zones for status. + var zones bindv1alpha1.BindZoneList + members := int32(0) + if err := r.List(ctx, &zones, client.InNamespace(catalog.Namespace)); err == nil { + for i := range zones.Items { + z := &zones.Items[i] + if z.Spec.ClusterRef == cluster.Name && catalogEnabled(z) { + members++ + } + } + } + + catalog.Status.Ready = true + catalog.Status.MemberCount = members + catalog.Status.ObservedGeneration = catalog.Generation + setReady(&catalog.Status.Conditions, catalog.Generation, true, "Ready", "catalog zone provisioned") + if err := r.Status().Update(ctx, &catalog); err != nil { + return ctrl.Result{}, err + } + logger.Info("catalog zone reconciled", "zone", catalog.Spec.ZoneName, "members", members) + return ctrl.Result{RequeueAfter: requeueLong}, nil +} + +func (r *BindCatalogZoneReconciler) fail(ctx context.Context, catalog *bindv1alpha1.BindCatalogZone, reason, msg string) (ctrl.Result, error) { + catalog.Status.Ready = false + catalog.Status.ObservedGeneration = catalog.Generation + setReady(&catalog.Status.Conditions, catalog.Generation, false, reason, msg) + if err := r.Status().Update(ctx, catalog); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: requeueShort}, nil +} + +func (r *BindCatalogZoneReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindCatalogZone{}). + Complete(r) +} diff --git a/internal/controller/bindcluster_controller.go b/internal/controller/bindcluster_controller.go new file mode 100644 index 0000000..c9b687e --- /dev/null +++ b/internal/controller/bindcluster_controller.go @@ -0,0 +1,418 @@ +package controller + +import ( + "context" + "fmt" + "sort" + "strings" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// BindClusterReconciler manages the StatefulSet, Services, ConfigMap and +// Secrets backing a BindCluster, and re-renders named.conf when dependent +// objects (ACLs, views, policies, keys, catalog) change. +type BindClusterReconciler struct { + client.Client + Scheme *runtime.Scheme + Exec *bind.Executor +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindclusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindclusters/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindacls;bindviews;bindpolicies;binddnssecpolicies;bindcatalogzones;bindtsigkeys,verbs=get;list;watch +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=services;configmaps;secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=pods/exec,verbs=create;get + +func (r *BindClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var cluster bindv1alpha1.BindCluster + if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if err := r.reconcileRNDCSecret(ctx, &cluster); err != nil { + return ctrl.Result{}, fmt.Errorf("rndc secret: %w", err) + } + if err := r.reconcileKeysSecret(ctx, &cluster); err != nil { + return ctrl.Result{}, fmt.Errorf("keys secret: %w", err) + } + if err := r.reconcileConfigMap(ctx, &cluster); err != nil { + return ctrl.Result{}, fmt.Errorf("configmap: %w", err) + } + if err := r.reconcileServices(ctx, &cluster); err != nil { + return ctrl.Result{}, fmt.Errorf("services: %w", err) + } + sts, err := r.reconcileStatefulSet(ctx, &cluster) + if err != nil { + return ctrl.Result{}, fmt.Errorf("statefulset: %w", err) + } + + // Best-effort: reload configuration on ready pods so ConfigMap changes take + // effect without a rollout. + r.reloadReadyPods(ctx, &cluster) + + // Status. + cluster.Status.ObservedGeneration = cluster.Generation + cluster.Status.Replicas = cluster.Spec.Replicas + cluster.Status.ReadyReplicas = sts.Status.ReadyReplicas + cluster.Status.PrimaryPod = primaryPodName(cluster.Name) + cluster.Status.PrimaryService = primaryAddress(cluster.Name, cluster.Namespace) + ready := sts.Status.ReadyReplicas == cluster.Spec.Replicas && cluster.Spec.Replicas > 0 + if ready { + cluster.Status.Phase = "Ready" + } else { + cluster.Status.Phase = "Progressing" + } + setReady(&cluster.Status.Conditions, cluster.Generation, ready, "Reconciled", + fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, cluster.Spec.Replicas)) + if err := r.Status().Update(ctx, &cluster); err != nil { + return ctrl.Result{}, err + } + + if !ready { + return ctrl.Result{RequeueAfter: requeueShort}, nil + } + logger.V(1).Info("cluster reconciled", "cluster", cluster.Name, "ready", sts.Status.ReadyReplicas) + return ctrl.Result{}, nil +} + +func (r *BindClusterReconciler) reconcileRNDCSecret(ctx context.Context, c *bindv1alpha1.BindCluster) error { + name := rndcSecretName(c.Name) + var existing corev1.Secret + err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing) + if err == nil { + return nil // rndc key is generated once and preserved + } + if !apierrors.IsNotFound(err) { + return err + } + secret, genErr := bind.GenerateSecret(32) + if genErr != nil { + return genErr + } + keyClause := bind.KeyClause("rndc-key", "hmac-sha256", secret) + rndcConf := fmt.Sprintf("include \"/etc/bind/rndc.key\";\noptions {\n default-key \"rndc-key\";\n default-server 127.0.0.1;\n default-port 953;\n};\n") + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: c.Namespace, Labels: commonLabels(c.Name)}, + Data: map[string][]byte{ + "rndc.key": []byte(keyClause), + "rndc.conf": []byte(rndcConf), + }, + } + if err := ctrl.SetControllerReference(c, s, r.Scheme); err != nil { + return err + } + return r.Create(ctx, s) +} + +func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bindv1alpha1.BindCluster) error { + var keys bindv1alpha1.BindTSIGKeyList + if err := r.List(ctx, &keys, client.InNamespace(c.Namespace)); err != nil { + return err + } + items := append([]bindv1alpha1.BindTSIGKey(nil), keys.Items...) + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + + var b strings.Builder + b.WriteString("// Managed by bind-operator.\n") + for _, k := range items { + secretName := k.Status.SecretName + if secretName == "" { + secretName = k.Spec.SecretName + } + if secretName == "" { + secretName = k.Name + "-tsig" + } + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: secretName}, &secret); err != nil { + continue // key not yet materialised; skip until its controller runs + } + keyName := k.Spec.KeyName + if keyName == "" { + keyName = k.Name + } + alg := string(secret.Data["algorithm"]) + if alg == "" { + alg = string(bindv1alpha1.TSIGHMACSHA256) + } + b.WriteString(bind.KeyClause(keyName, alg, string(secret.Data["secret"]))) + } + + return r.upsertSecret(ctx, c, keysSecretName(c.Name), map[string][]byte{"keys.conf": []byte(b.String())}) +} + +func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error { + in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryAddress(c.Name, c.Namespace)} + + var acls bindv1alpha1.BindACLList + if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil { + for _, a := range acls.Items { + if a.Spec.ClusterRef == "" || a.Spec.ClusterRef == c.Name { + in.ACLs = append(in.ACLs, a) + } + } + } + var views bindv1alpha1.BindViewList + if err := r.List(ctx, &views, client.InNamespace(c.Namespace)); err == nil { + for _, v := range views.Items { + if v.Spec.ClusterRef == c.Name { + in.Views = append(in.Views, v) + } + } + } + var policies bindv1alpha1.BindPolicyList + if err := r.List(ctx, &policies, client.InNamespace(c.Namespace)); err == nil { + for _, p := range policies.Items { + if p.Spec.ClusterRef == c.Name { + in.Policies = append(in.Policies, p) + } + } + } + var dnssec bindv1alpha1.BindDNSSECPolicyList + if err := r.List(ctx, &dnssec, client.InNamespace(c.Namespace)); err == nil { + for _, d := range dnssec.Items { + if d.Spec.ClusterRef == c.Name { + in.DNSSECPolicies = append(in.DNSSECPolicies, d) + } + } + } + + var catalogs bindv1alpha1.BindCatalogZoneList + if err := r.List(ctx, &catalogs, client.InNamespace(c.Namespace)); err == nil { + for i := range catalogs.Items { + if catalogs.Items[i].Spec.ClusterRef == c.Name { + in.Catalog = &catalogs.Items[i] + break + } + } + } + + primaryConf, secondaryConf := bind.RenderNamedConf(in) + data := map[string]string{ + "named.conf.primary": primaryConf, + "named.conf.secondary": secondaryConf, + "entrypoint.sh": entrypointScript(), + } + return r.upsertConfigMap(ctx, c, configMapName(c.Name), data) +} + +func (r *BindClusterReconciler) reconcileServices(ctx context.Context, c *bindv1alpha1.BindCluster) error { + dnsPorts := []corev1.ServicePort{ + {Name: "dns-udp", Port: 53, Protocol: corev1.ProtocolUDP, TargetPort: intstrFromInt(53)}, + {Name: "dns-tcp", Port: 53, Protocol: corev1.ProtocolTCP, TargetPort: intstrFromInt(53)}, + } + + headless := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: headlessServiceName(c.Name), Namespace: c.Namespace, Labels: commonLabels(c.Name)}, + Spec: corev1.ServiceSpec{ + ClusterIP: corev1.ClusterIPNone, + PublishNotReadyAddresses: true, + Selector: commonLabels(c.Name), + Ports: dnsPorts, + }, + } + if err := r.upsertService(ctx, c, headless); err != nil { + return err + } + + svcType := c.Spec.Service.Type + if svcType == "" { + svcType = corev1.ServiceTypeClusterIP + } + client := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: clientServiceName(c.Name), + Namespace: c.Namespace, + Labels: commonLabels(c.Name), + Annotations: c.Spec.Service.Annotations, + }, + Spec: corev1.ServiceSpec{ + Type: svcType, + Selector: commonLabels(c.Name), + Ports: dnsPorts, + LoadBalancerIP: c.Spec.Service.LoadBalancerIP, + }, + } + return r.upsertService(ctx, c, client) +} + +func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) { + labels := commonLabels(c.Name) + replicas := c.Spec.Replicas + image := c.Spec.Image + if image == "" { + image = "git.unkin.net/unkin/bind9:latest" + } + storageSize := c.Spec.StorageSize + if storageSize == "" { + storageSize = "1Gi" + } + qty, err := resource.ParseQuantity(storageSize) + if err != nil { + return nil, fmt.Errorf("parse storageSize: %w", err) + } + + projected := corev1.Volume{ + Name: "bind-etc", + VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{Sources: []corev1.VolumeProjection{ + {ConfigMap: &corev1.ConfigMapProjection{LocalObjectReference: corev1.LocalObjectReference{Name: configMapName(c.Name)}}}, + {Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{Name: keysSecretName(c.Name)}}}, + {Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{Name: rndcSecretName(c.Name)}}}, + }}}, + } + + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: c.Name, Namespace: c.Namespace, Labels: labels}, + Spec: appsv1.StatefulSetSpec{ + ServiceName: headlessServiceName(c.Name), + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + NodeSelector: c.Spec.NodeSelector, + Tolerations: c.Spec.Tolerations, + Affinity: c.Spec.Affinity, + Containers: []corev1.Container{{ + Name: bind.ContainerName, + Image: image, + ImagePullPolicy: c.Spec.ImagePullPolicy, + Command: []string{"/bin/sh", "/etc/bind/entrypoint.sh"}, + Ports: []corev1.ContainerPort{ + {Name: "dns-udp", ContainerPort: 53, Protocol: corev1.ProtocolUDP}, + {Name: "dns-tcp", ContainerPort: 53, Protocol: corev1.ProtocolTCP}, + }, + Resources: c.Spec.Resources, + VolumeMounts: []corev1.VolumeMount{ + {Name: "bind-etc", MountPath: "/etc/bind", ReadOnly: true}, + {Name: "run", MountPath: "/run/named"}, + {Name: "data", MountPath: bind.DataDir}, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(53)}}, + InitialDelaySeconds: 5, + PeriodSeconds: 10, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstrFromInt(53)}}, + InitialDelaySeconds: 15, + PeriodSeconds: 20, + }, + }}, + Volumes: []corev1.Volume{ + projected, + {Name: "run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + StorageClassName: c.Spec.StorageClassName, + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: qty}}, + }, + }}, + }, + } + if err := ctrl.SetControllerReference(c, sts, r.Scheme); err != nil { + return nil, err + } + + var existing appsv1.StatefulSet + err = r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: c.Name}, &existing) + if apierrors.IsNotFound(err) { + return sts, r.Create(ctx, sts) + } + if err != nil { + return nil, err + } + // VolumeClaimTemplates are immutable; only mutate the mutable fields. + existing.Spec.Replicas = sts.Spec.Replicas + existing.Spec.Template = sts.Spec.Template + if err := r.Update(ctx, &existing); err != nil { + return nil, err + } + return &existing, nil +} + +func (r *BindClusterReconciler) reloadReadyPods(ctx context.Context, c *bindv1alpha1.BindCluster) { + if r.Exec == nil { + return + } + logger := log.FromContext(ctx) + var pods corev1.PodList + if err := r.List(ctx, &pods, client.InNamespace(c.Namespace), client.MatchingLabels(commonLabels(c.Name))); err != nil { + return + } + for i := range pods.Items { + pod := &pods.Items[i] + if !podReady(pod) { + continue + } + if err := r.Exec.Reconfig(ctx, c.Namespace, pod.Name); err != nil { + logger.V(1).Info("rndc reconfig failed", "pod", pod.Name, "err", err.Error()) + } + } +} + +func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + mapToCluster := func(clusterRef, namespace string) []reconcile.Request { + if clusterRef == "" { + return nil + } + return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: namespace, Name: clusterRef}}} + } + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindCluster{}). + Owns(&appsv1.StatefulSet{}). + Owns(&corev1.Service{}). + Owns(&corev1.ConfigMap{}). + Owns(&corev1.Secret{}). + Watches(&bindv1alpha1.BindACL{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + return mapToCluster(o.(*bindv1alpha1.BindACL).Spec.ClusterRef, o.GetNamespace()) + })). + Watches(&bindv1alpha1.BindView{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + return mapToCluster(o.(*bindv1alpha1.BindView).Spec.ClusterRef, o.GetNamespace()) + })). + Watches(&bindv1alpha1.BindPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + return mapToCluster(o.(*bindv1alpha1.BindPolicy).Spec.ClusterRef, o.GetNamespace()) + })). + Watches(&bindv1alpha1.BindDNSSECPolicy{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + return mapToCluster(o.(*bindv1alpha1.BindDNSSECPolicy).Spec.ClusterRef, o.GetNamespace()) + })). + Watches(&bindv1alpha1.BindCatalogZone{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + return mapToCluster(o.(*bindv1alpha1.BindCatalogZone).Spec.ClusterRef, o.GetNamespace()) + })). + Watches(&bindv1alpha1.BindTSIGKey{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + // TSIG keys are namespace-wide; re-render every cluster in the namespace. + var clusters bindv1alpha1.BindClusterList + if err := r.List(ctx, &clusters, client.InNamespace(o.GetNamespace())); err != nil { + return nil + } + var reqs []reconcile.Request + for _, cl := range clusters.Items { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: cl.Namespace, Name: cl.Name}}) + } + return reqs + })). + Complete(r) +} diff --git a/internal/controller/binddnssecpolicy_controller.go b/internal/controller/binddnssecpolicy_controller.go new file mode 100644 index 0000000..3f98f5d --- /dev/null +++ b/internal/controller/binddnssecpolicy_controller.go @@ -0,0 +1,55 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +// BindDNSSECPolicyReconciler validates a signing policy and reports how many +// zones reference it. The dnssec-policy block is rendered into named.conf by +// the BindCluster controller, which watches these policies. +type BindDNSSECPolicyReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=binddnssecpolicies,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=binddnssecpolicies/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch + +func (r *BindDNSSECPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var policy bindv1alpha1.BindDNSSECPolicy + if err := r.Get(ctx, req.NamespacedName, &policy); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + var zones bindv1alpha1.BindZoneList + count := int32(0) + if err := r.List(ctx, &zones, client.InNamespace(policy.Namespace)); err == nil { + for _, z := range zones.Items { + if z.Spec.ClusterRef == policy.Spec.ClusterRef && z.Spec.DNSSECPolicyRef == policy.Name { + count++ + } + } + } + + policy.Status.ZoneCount = count + policy.Status.Ready = policy.Spec.ClusterRef != "" + policy.Status.ObservedGeneration = policy.Generation + setReady(&policy.Status.Conditions, policy.Generation, policy.Status.Ready, "Validated", "dnssec-policy rendered into named.conf") + if err := r.Status().Update(ctx, &policy); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *BindDNSSECPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindDNSSECPolicy{}). + Complete(r) +} diff --git a/internal/controller/bindpolicy_controller.go b/internal/controller/bindpolicy_controller.go new file mode 100644 index 0000000..68419da --- /dev/null +++ b/internal/controller/bindpolicy_controller.go @@ -0,0 +1,163 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// BindPolicyReconciler provisions a Response Policy Zone (RPZ) on a cluster +// primary and seeds its rules. The cluster controller renders the matching +// response-policy clause into named.conf. +type BindPolicyReconciler struct { + client.Client + Scheme *runtime.Scheme + Exec *bind.Executor +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindpolicies/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch + +func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var policy bindv1alpha1.BindPolicy + if err := r.Get(ctx, req.NamespacedName, &policy); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + cluster, err := getCluster(ctx, r.Client, policy.Namespace, policy.Spec.ClusterRef) + if err != nil { + return r.fail(ctx, &policy, "ClusterMissing", err.Error()) + } + primaryPod := primaryPodName(cluster.Name) + if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil { + return r.fail(ctx, &policy, "PrimaryNotReady", "waiting for cluster primary") + } + + // Externally-fed RPZ: configure as a secondary of the feed. Otherwise host a + // locally-populated primary RPZ zone. + if len(policy.Spec.Primaries) > 0 { + creds, _ := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef) + _ = creds + cfg := fmt.Sprintf("{ type secondary; file \"%s\"; primaries { %s }; };", + bind.ZoneFilePath(policy.Spec.ZoneName), terminateInline(policy.Spec.Primaries)) + if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil { + return r.fail(ctx, &policy, "AddZoneFailed", err.Error()) + } + return r.ready(ctx, &policy, int32(0)) + } + + creds, err := resolveTSIG(ctx, r.Client, policy.Namespace, policy.Spec.TransferKeyRef) + if err != nil { + return r.fail(ctx, &policy, "NoUpdateKey", "spec.transferKeyRef required to seed RPZ rules") + } + + if !r.Exec.ZoneExists(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef) { + if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), "", 1); err != nil { + return r.fail(ctx, &policy, "SeedFailed", err.Error()) + } + } + cfg := fmt.Sprintf("{ type primary; file \"%s\"; allow-update { key \"%s\"; }; };", + bind.ZoneFilePath(policy.Spec.ZoneName), policy.Spec.TransferKeyRef) + if err := r.Exec.AddZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef, cfg); err != nil { + return r.fail(ctx, &policy, "AddZoneFailed", err.Error()) + } + + updates := rpzRulesToUpdates(policy.Spec.ZoneName, policy.Spec.Rules) + if len(updates) > 0 { + if err := r.Exec.NSUpdate(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, creds, updates); err != nil { + return r.fail(ctx, &policy, "RuleUpdateFailed", err.Error()) + } + } + logger.Info("policy reconciled", "zone", policy.Spec.ZoneName, "rules", len(updates)) + return r.ready(ctx, &policy, int32(len(updates))) +} + +// rpzRulesToUpdates maps RPZ rules to the CNAME records that encode them. +func rpzRulesToUpdates(rpzZone string, rules []bindv1alpha1.RPZRule) []bind.RecordUpdate { + var updates []bind.RecordUpdate + origin := strings.TrimSuffix(rpzZone, ".") + "." + for _, rule := range rules { + trigger := rule.Trigger + if trigger == "" { + trigger = "qname" + } + match := strings.TrimSuffix(strings.TrimSpace(rule.Match), ".") + var owner string + switch trigger { + case "qname": + owner = match + "." + origin + case "client-ip": + owner = match + ".rpz-client-ip." + origin + case "ip": + owner = match + ".rpz-ip." + origin + case "nsdname": + owner = match + ".rpz-nsdname." + origin + case "nsip": + owner = match + ".rpz-nsip." + origin + default: + owner = match + "." + origin + } + + action := rule.Action + if action == "" { + action = "nxdomain" + } + var rdata string + switch action { + case "nxdomain": + rdata = "." + case "nodata": + rdata = "*." + case "passthru": + rdata = "rpz-passthru." + case "drop": + rdata = "rpz-drop." + case "tcp-only": + rdata = "rpz-tcp-only." + case "cname": + rdata = strings.TrimSuffix(rule.Target, ".") + "." + default: + rdata = "." + } + updates = append(updates, bind.RecordUpdate{FQDN: owner, Type: "CNAME", TTL: 3600, Values: []string{rdata}}) + } + return updates +} + +func (r *BindPolicyReconciler) ready(ctx context.Context, policy *bindv1alpha1.BindPolicy, rules int32) (ctrl.Result, error) { + policy.Status.Ready = true + policy.Status.RuleCount = rules + policy.Status.ObservedGeneration = policy.Generation + setReady(&policy.Status.Conditions, policy.Generation, true, "Ready", "RPZ provisioned") + if err := r.Status().Update(ctx, policy); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: requeueLong}, nil +} + +func (r *BindPolicyReconciler) fail(ctx context.Context, policy *bindv1alpha1.BindPolicy, reason, msg string) (ctrl.Result, error) { + policy.Status.Ready = false + policy.Status.ObservedGeneration = policy.Generation + setReady(&policy.Status.Conditions, policy.Generation, false, reason, msg) + if err := r.Status().Update(ctx, policy); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: requeueShort}, nil +} + +func (r *BindPolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindPolicy{}). + Complete(r) +} diff --git a/internal/controller/bindtsigkey_controller.go b/internal/controller/bindtsigkey_controller.go new file mode 100644 index 0000000..23466cc --- /dev/null +++ b/internal/controller/bindtsigkey_controller.go @@ -0,0 +1,108 @@ +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// BindTSIGKeyReconciler generates TSIG key material into a Secret. +type BindTSIGKeyReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigkeys/status,verbs=get;update;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete + +func (r *BindTSIGKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var key bindv1alpha1.BindTSIGKey + if err := r.Get(ctx, req.NamespacedName, &key); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + algorithm := string(key.Spec.Algorithm) + if algorithm == "" { + algorithm = string(bindv1alpha1.TSIGHMACSHA256) + } + keyName := key.Spec.KeyName + if keyName == "" { + keyName = key.Name + } + secretName := key.Spec.SecretName + if secretName == "" { + secretName = key.Name + "-tsig" + } + + var secret corev1.Secret + err := r.Get(ctx, types.NamespacedName{Namespace: key.Namespace, Name: secretName}, &secret) + switch { + case apierrors.IsNotFound(err): + if key.Spec.ImportExisting { + return r.fail(ctx, &key, "SecretMissing", fmt.Sprintf("import secret %s not found", secretName)) + } + material, genErr := bind.GenerateSecret(bind.SecretBytesForAlgorithm(algorithm)) + if genErr != nil { + return ctrl.Result{}, genErr + } + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: key.Namespace, Labels: map[string]string{managedByLabel: managedByValue}}, + Data: map[string][]byte{ + "algorithm": []byte(algorithm), + "keyName": []byte(keyName), + "secret": []byte(material), + "key.conf": []byte(bind.KeyClause(keyName, algorithm, material)), + }, + } + if err := ctrl.SetControllerReference(&key, newSecret, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Create(ctx, newSecret); err != nil { + return ctrl.Result{}, err + } + logger.Info("generated TSIG key", "key", key.Name, "secret", secretName) + case err != nil: + return ctrl.Result{}, err + } + + key.Status.SecretName = secretName + key.Status.KeyName = keyName + key.Status.Ready = true + key.Status.ObservedGeneration = key.Generation + setReady(&key.Status.Conditions, key.Generation, true, "KeyReady", "TSIG key material present") + if err := r.Status().Update(ctx, &key); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *BindTSIGKeyReconciler) fail(ctx context.Context, key *bindv1alpha1.BindTSIGKey, reason, msg string) (ctrl.Result, error) { + key.Status.Ready = false + key.Status.ObservedGeneration = key.Generation + setReady(&key.Status.Conditions, key.Generation, false, reason, msg) + if err := r.Status().Update(ctx, key); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: requeueShort}, nil +} + +func (r *BindTSIGKeyReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindTSIGKey{}). + Owns(&corev1.Secret{}). + Complete(r) +} diff --git a/internal/controller/bindview_controller.go b/internal/controller/bindview_controller.go new file mode 100644 index 0000000..316dad0 --- /dev/null +++ b/internal/controller/bindview_controller.go @@ -0,0 +1,55 @@ +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +// BindViewReconciler validates a BindView and reports the number of zones bound +// to it. The view block is rendered into named.conf by the BindCluster +// controller, which watches views. +type BindViewReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindviews,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindviews/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch + +func (r *BindViewReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var view bindv1alpha1.BindView + if err := r.Get(ctx, req.NamespacedName, &view); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + var zones bindv1alpha1.BindZoneList + count := int32(0) + if err := r.List(ctx, &zones, client.InNamespace(view.Namespace)); err == nil { + for _, z := range zones.Items { + if z.Spec.ClusterRef == view.Spec.ClusterRef && z.Spec.ViewRef == view.Name { + count++ + } + } + } + + view.Status.ZoneCount = count + view.Status.Ready = view.Spec.ClusterRef != "" + view.Status.ObservedGeneration = view.Generation + setReady(&view.Status.Conditions, view.Generation, view.Status.Ready, "Validated", "view rendered into named.conf") + if err := r.Status().Update(ctx, &view); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *BindViewReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindView{}). + Complete(r) +} diff --git a/internal/controller/bindzone_controller.go b/internal/controller/bindzone_controller.go new file mode 100644 index 0000000..57983f5 --- /dev/null +++ b/internal/controller/bindzone_controller.go @@ -0,0 +1,223 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// BindZoneReconciler provisions zones on a cluster primary via rndc addzone and +// seeds records via dynamic update. +type BindZoneReconciler struct { + client.Client + Scheme *runtime.Scheme + Exec *bind.Executor +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones;bindtsigkeys,verbs=get;list;watch + +func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var zone bindv1alpha1.BindZone + if err := r.Get(ctx, req.NamespacedName, &zone); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef) + if err != nil { + return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error()) + } + primaryPod := primaryPodName(cluster.Name) + + // Handle deletion via finalizer: remove the zone from the primary and catalog. + if !zone.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(&zone, finalizer) { + if primaryReady(ctx, r.Client, cluster) && r.Exec != nil { + _ = r.Exec.DelZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) + r.deregisterCatalog(ctx, &zone, cluster, primaryPod) + } + controllerutil.RemoveFinalizer(&zone, finalizer) + if err := r.Update(ctx, &zone); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + if !controllerutil.ContainsFinalizer(&zone, finalizer) { + controllerutil.AddFinalizer(&zone, finalizer) + if err := r.Update(ctx, &zone); err != nil { + return ctrl.Result{}, err + } + } + + if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil { + return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready") + } + + zoneConfig, err := r.buildZoneConfig(ctx, &zone) + if err != nil { + return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) + } + + created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) + if created && zone.Spec.Type == bindv1alpha1.ZonePrimary || (created && zone.Spec.Type == "") { + if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), "", 1); err != nil { + return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error()) + } + } + if err := r.Exec.AddZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef, zoneConfig); err != nil { + return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error()) + } + + // Seed static records (primary zones only). + recordCount := 0 + if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 { + creds, err := r.zoneUpdateCreds(ctx, &zone) + if err != nil { + return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error()) + } + updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL) + if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil { + return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error()) + } + recordCount = len(updates) + } + + // Register in the catalog so secondaries auto-provision. + if catalogEnabled(&zone) { + r.registerCatalog(ctx, &zone, cluster, primaryPod) + } + + serial, _ := r.Exec.ZoneSerial(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) + zone.Status.Phase = "Ready" + zone.Status.Serial = serial + zone.Status.RecordCount = int32(recordCount) + zone.Status.Signed = zone.Spec.DNSSECPolicyRef != "" + zone.Status.ObservedGeneration = zone.Generation + setReady(&zone.Status.Conditions, zone.Generation, true, "Provisioned", "zone provisioned on primary") + if err := r.Status().Update(ctx, &zone); err != nil { + return ctrl.Result{}, err + } + logger.Info("zone reconciled", "zone", zone.Spec.ZoneName, "serial", serial) + return ctrl.Result{RequeueAfter: requeueLong}, nil +} + +// buildZoneConfig renders the inner clause passed to rndc addzone/modzone. +func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone) (string, error) { + zType := zone.Spec.Type + if zType == "" { + zType = bindv1alpha1.ZonePrimary + } + var parts []string + switch zType { + case bindv1alpha1.ZonePrimary: + parts = append(parts, "type primary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName))) + if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" { + parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone))) + } + if len(zone.Spec.AllowTransfer) > 0 { + parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer))) + } + if zone.Spec.DNSSECPolicyRef != "" { + parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes") + } + case bindv1alpha1.ZoneSecondary: + parts = append(parts, "type secondary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName))) + if len(zone.Spec.Primaries) > 0 { + parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries))) + } + case bindv1alpha1.ZoneForward: + parts = append(parts, "type forward", "forward only") + if len(zone.Spec.Forwarders) > 0 { + parts = append(parts, fmt.Sprintf("forwarders { %s }", terminateInline(zone.Spec.Forwarders))) + } + case bindv1alpha1.ZoneStub: + parts = append(parts, "type stub", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName))) + if len(zone.Spec.Primaries) > 0 { + parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries))) + } + } + return "{ " + strings.Join(parts, "; ") + "; };", nil +} + +func (r *BindZoneReconciler) zoneUpdateCreds(ctx context.Context, zone *bindv1alpha1.BindZone) (bind.TSIGCreds, error) { + keyRef := zone.Spec.UpdateKeyRef + if keyRef == "" { + keyRef = zone.Spec.TransferKeyRef + } + if keyRef == "" { + // Fall back to local (non-TSIG) update when the zone allows it; most + // seeded primaries permit localhost updates. + return bind.TSIGCreds{}, fmt.Errorf("records require spec.updateKeyRef") + } + return resolveTSIG(ctx, r.Client, zone.Namespace, keyRef) +} + +func (r *BindZoneReconciler) registerCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) { + logger := log.FromContext(ctx) + catalog, creds, ok := r.catalogFor(ctx, zone, cluster) + if !ok { + return + } + if err := r.Exec.AddCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds); err != nil { + logger.V(1).Info("catalog register failed", "zone", zone.Spec.ZoneName, "err", err.Error()) + } +} + +func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) { + catalog, creds, ok := r.catalogFor(ctx, zone, cluster) + if !ok { + return + } + _ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds) +} + +func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) { + var catalogs bindv1alpha1.BindCatalogZoneList + if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil { + return nil, bind.TSIGCreds{}, false + } + for i := range catalogs.Items { + if catalogs.Items[i].Spec.ClusterRef == cluster.Name { + cat := &catalogs.Items[i] + creds, err := resolveTSIG(ctx, r.Client, zone.Namespace, cat.Spec.TransferKeyRef) + if err != nil { + return nil, bind.TSIGCreds{}, false + } + return cat, creds, true + } + } + return nil, bind.TSIGCreds{}, false +} + +func (r *BindZoneReconciler) setPhase(ctx context.Context, zone *bindv1alpha1.BindZone, phase, reason, msg string) (ctrl.Result, error) { + zone.Status.Phase = phase + zone.Status.ObservedGeneration = zone.Generation + setReady(&zone.Status.Conditions, zone.Generation, phase == "Ready", reason, msg) + if err := r.Status().Update(ctx, zone); err != nil { + return ctrl.Result{}, err + } + if phase == "Error" || phase == "Pending" { + return ctrl.Result{RequeueAfter: requeueShort}, nil + } + return ctrl.Result{}, nil +} + +func (r *BindZoneReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.BindZone{}). + Complete(r) +} diff --git a/internal/controller/dnsrecord_controller.go b/internal/controller/dnsrecord_controller.go new file mode 100644 index 0000000..a1238bc --- /dev/null +++ b/internal/controller/dnsrecord_controller.go @@ -0,0 +1,116 @@ +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// DNSRecordReconciler applies individual record sets to a zone via TSIG dynamic +// update — the external-dns write path as a CRD. +type DNSRecordReconciler struct { + client.Client + Scheme *runtime.Scheme + Exec *bind.Executor +} + +// +kubebuilder:rbac:groups=bind.unkin.net,resources=dnsrecords,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=bind.unkin.net,resources=dnsrecords/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones;bindtsigkeys,verbs=get;list;watch + +func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var record bindv1alpha1.DNSRecord + if err := r.Get(ctx, req.NamespacedName, &record); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + var zone bindv1alpha1.BindZone + if err := r.Get(ctx, client.ObjectKey{Namespace: record.Namespace, Name: record.Spec.ZoneRef}, &zone); err != nil { + return r.setPhase(ctx, &record, "Error", "ZoneMissing", err.Error()) + } + cluster, err := getCluster(ctx, r.Client, record.Namespace, zone.Spec.ClusterRef) + if err != nil { + return r.setPhase(ctx, &record, "Error", "ClusterMissing", err.Error()) + } + primaryPod := primaryPodName(cluster.Name) + name := fqdn(record.Spec.Name, zone.Spec.ZoneName) + + creds, err := resolveTSIG(ctx, r.Client, record.Namespace, zone.Spec.UpdateKeyRef) + if err != nil { + return r.setPhase(ctx, &record, "Error", "NoUpdateKey", fmt.Sprintf("zone %s: %v", zone.Name, err)) + } + + // Deletion via finalizer: remove the RRset. + if !record.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(&record, finalizer) { + if primaryReady(ctx, r.Client, cluster) && r.Exec != nil { + _ = r.Exec.NSUpdate(ctx, record.Namespace, primaryPod, zone.Spec.ZoneName, creds, + []bind.RecordUpdate{{FQDN: name, Type: record.Spec.Type, Delete: true}}) + } + controllerutil.RemoveFinalizer(&record, finalizer) + if err := r.Update(ctx, &record); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + if !controllerutil.ContainsFinalizer(&record, finalizer) { + controllerutil.AddFinalizer(&record, finalizer) + if err := r.Update(ctx, &record); err != nil { + return ctrl.Result{}, err + } + } + + if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil { + return r.setPhase(ctx, &record, "Pending", "PrimaryNotReady", "waiting for cluster primary") + } + + ttl := zone.Spec.DefaultTTL + if record.Spec.TTL != nil { + ttl = *record.Spec.TTL + } + update := bind.RecordUpdate{FQDN: name, Type: record.Spec.Type, TTL: ttl, Values: record.Spec.Values} + if err := r.Exec.NSUpdate(ctx, record.Namespace, primaryPod, zone.Spec.ZoneName, creds, []bind.RecordUpdate{update}); err != nil { + return r.setPhase(ctx, &record, "Error", "UpdateFailed", err.Error()) + } + + record.Status.FQDN = name + record.Status.Phase = "Applied" + record.Status.ObservedGeneration = record.Generation + setReady(&record.Status.Conditions, record.Generation, true, "Applied", "record applied via dynamic update") + if err := r.Status().Update(ctx, &record); err != nil { + return ctrl.Result{}, err + } + logger.Info("record applied", "record", name, "type", record.Spec.Type) + return ctrl.Result{}, nil +} + +func (r *DNSRecordReconciler) setPhase(ctx context.Context, record *bindv1alpha1.DNSRecord, phase, reason, msg string) (ctrl.Result, error) { + record.Status.Phase = phase + record.Status.ObservedGeneration = record.Generation + setReady(&record.Status.Conditions, record.Generation, phase == "Applied", reason, msg) + if err := r.Status().Update(ctx, record); err != nil { + return ctrl.Result{}, err + } + if phase == "Error" || phase == "Pending" { + return ctrl.Result{RequeueAfter: requeueShort}, nil + } + return ctrl.Result{}, nil +} + +func (r *DNSRecordReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bindv1alpha1.DNSRecord{}). + Complete(r) +} diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go new file mode 100644 index 0000000..33a2758 --- /dev/null +++ b/internal/controller/helpers.go @@ -0,0 +1,121 @@ +package controller + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +const ( + requeueShort = 15 * time.Second + requeueLong = 2 * time.Minute + + managedByLabel = "app.kubernetes.io/managed-by" + managedByValue = "bind-operator" + clusterLabel = "bind.unkin.net/cluster" + + finalizer = "bind.unkin.net/finalizer" +) + +func headlessServiceName(cluster string) string { return cluster + "-headless" } +func clientServiceName(cluster string) string { return cluster } +func primaryPodName(cluster string) string { return cluster + "-0" } +func configMapName(cluster string) string { return cluster + "-config" } +func keysSecretName(cluster string) string { return cluster + "-keys" } +func rndcSecretName(cluster string) string { return cluster + "-rndc" } + +// primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0). +func primaryAddress(cluster, namespace string) string { + return fmt.Sprintf("%s-0.%s.%s.svc.cluster.local", cluster, headlessServiceName(cluster), namespace) +} + +// setReady sets the standard Ready condition on a status conditions slice. +func setReady(conds *[]metav1.Condition, gen int64, ok bool, reason, msg string) { + status := metav1.ConditionFalse + if ok { + status = metav1.ConditionTrue + } + meta.SetStatusCondition(conds, metav1.Condition{ + Type: "Ready", + Status: status, + Reason: reason, + Message: msg, + ObservedGeneration: gen, + }) +} + +// commonLabels are applied to every object the operator creates for a cluster. +func commonLabels(cluster string) map[string]string { + return map[string]string{ + managedByLabel: managedByValue, + clusterLabel: cluster, + } +} + +// getCluster fetches the BindCluster referenced by clusterRef in namespace. +func getCluster(ctx context.Context, c client.Client, namespace, clusterRef string) (*bindv1alpha1.BindCluster, error) { + var cluster bindv1alpha1.BindCluster + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: clusterRef}, &cluster); err != nil { + return nil, err + } + return &cluster, nil +} + +// primaryReady reports whether the primary pod of a cluster is Ready. +func primaryReady(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) bool { + var pod corev1.Pod + key := client.ObjectKey{Namespace: cluster.Namespace, Name: primaryPodName(cluster.Name)} + if err := c.Get(ctx, key, &pod); err != nil { + return false + } + for _, cond := range pod.Status.Conditions { + if cond.Type == corev1.PodReady { + return cond.Status == corev1.ConditionTrue + } + } + return false +} + +// resolveTSIG reads the material of a BindTSIGKey into TSIG credentials. +func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) { + var creds bind.TSIGCreds + if keyRef == "" { + return creds, fmt.Errorf("no TSIG key referenced") + } + var key bindv1alpha1.BindTSIGKey + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: keyRef}, &key); err != nil { + return creds, fmt.Errorf("get tsig key %s: %w", keyRef, err) + } + secretName := key.Status.SecretName + if secretName == "" { + secretName = key.Spec.SecretName + } + if secretName == "" { + secretName = keyRef + "-tsig" + } + var secret corev1.Secret + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: secretName}, &secret); err != nil { + return creds, fmt.Errorf("get tsig secret %s: %w", secretName, err) + } + keyName := key.Spec.KeyName + if keyName == "" { + keyName = keyRef + } + creds = bind.TSIGCreds{ + Name: keyName, + Algorithm: string(secret.Data["algorithm"]), + Secret: string(secret.Data["secret"]), + } + if creds.Algorithm == "" { + creds.Algorithm = string(bindv1alpha1.TSIGHMACSHA256) + } + return creds, nil +} diff --git a/internal/controller/setup.go b/internal/controller/setup.go new file mode 100644 index 0000000..69d9673 --- /dev/null +++ b/internal/controller/setup.go @@ -0,0 +1,39 @@ +package controller + +import ( + ctrl "sigs.k8s.io/controller-runtime" + + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +// SetupAll registers every controller with the manager. +func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error { + if err := (&BindClusterReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindTSIGKeyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindACLReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindViewReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindDNSSECPolicyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindCatalogZoneReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindZoneReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&BindPolicyReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { + return err + } + if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil { + return err + } + return nil +} diff --git a/internal/controller/util.go b/internal/controller/util.go new file mode 100644 index 0000000..5a88994 --- /dev/null +++ b/internal/controller/util.go @@ -0,0 +1,111 @@ +package controller + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + ctrl "sigs.k8s.io/controller-runtime" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +func intstrFromInt(i int) intstr.IntOrString { return intstr.FromInt(i) } + +func podReady(pod *corev1.Pod) bool { + for _, c := range pod.Status.Conditions { + if c.Type == corev1.PodReady { + return c.Status == corev1.ConditionTrue + } + } + return false +} + +// entrypointScript selects the primary or secondary named.conf based on the +// pod's StatefulSet ordinal and launches named in the foreground. +func entrypointScript() string { + return `#!/bin/sh +set -eu +ORD="${HOSTNAME##*-}" +if [ "$ORD" = "0" ]; then + cp /etc/bind/named.conf.primary /run/named/named.conf +else + cp /etc/bind/named.conf.secondary /run/named/named.conf +fi +mkdir -p /var/lib/named/zones /var/lib/named/catalog +exec named -g -c /run/named/named.conf +` +} + +func (r *BindClusterReconciler) upsertService(ctx context.Context, c *bindv1alpha1.BindCluster, desired *corev1.Service) error { + if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil { + return err + } + var existing corev1.Service + err := r.Get(ctx, types.NamespacedName{Namespace: desired.Namespace, Name: desired.Name}, &existing) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + existing.Spec.Ports = desired.Spec.Ports + existing.Spec.Selector = desired.Spec.Selector + existing.Spec.Type = desired.Spec.Type + existing.Spec.LoadBalancerIP = desired.Spec.LoadBalancerIP + if desired.Annotations != nil { + if existing.Annotations == nil { + existing.Annotations = map[string]string{} + } + for k, v := range desired.Annotations { + existing.Annotations[k] = v + } + } + return r.Update(ctx, &existing) +} + +func (r *BindClusterReconciler) upsertConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster, name string, data map[string]string) error { + desired := &corev1.ConfigMap{} + desired.Name = name + desired.Namespace = c.Namespace + desired.Labels = commonLabels(c.Name) + desired.Data = data + if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil { + return err + } + var existing corev1.ConfigMap + err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + existing.Data = data + existing.Labels = commonLabels(c.Name) + return r.Update(ctx, &existing) +} + +func (r *BindClusterReconciler) upsertSecret(ctx context.Context, c *bindv1alpha1.BindCluster, name string, data map[string][]byte) error { + desired := &corev1.Secret{} + desired.Name = name + desired.Namespace = c.Namespace + desired.Labels = commonLabels(c.Name) + desired.Data = data + if err := ctrl.SetControllerReference(c, desired, r.Scheme); err != nil { + return err + } + var existing corev1.Secret + err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing) + if apierrors.IsNotFound(err) { + return r.Create(ctx, desired) + } + if err != nil { + return err + } + existing.Data = data + existing.Labels = commonLabels(c.Name) + return r.Update(ctx, &existing) +} diff --git a/internal/controller/zone_helpers.go b/internal/controller/zone_helpers.go new file mode 100644 index 0000000..340184f --- /dev/null +++ b/internal/controller/zone_helpers.go @@ -0,0 +1,88 @@ +package controller + +import ( + "context" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +func isPrimaryType(t bindv1alpha1.ZoneType) bool { + return t == bindv1alpha1.ZonePrimary || t == "" +} + +// catalogEnabled reports whether a primary zone should be registered in the +// cluster catalog zone. +func catalogEnabled(zone *bindv1alpha1.BindZone) bool { + if !isPrimaryType(zone.Spec.Type) { + return false + } + if zone.Spec.Catalog == nil { + return true + } + return *zone.Spec.Catalog +} + +// fqdn resolves a record owner name relative to a zone origin. +func fqdn(name, zone string) string { + zone = strings.TrimSuffix(zone, ".") + "." + if name == "" || name == "@" { + return zone + } + if strings.HasSuffix(name, ".") { + return name + } + return name + "." + zone +} + +func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate { + updates := make([]bind.RecordUpdate, 0, len(records)) + for _, rec := range records { + ttl := defaultTTL + if rec.TTL != nil { + ttl = *rec.TTL + } + updates = append(updates, bind.RecordUpdate{ + FQDN: fqdn(rec.Name, zone), + Type: rec.Type, + TTL: ttl, + Values: rec.Values, + }) + } + return updates +} + +// updateKeyName returns the TSIG key name (as used in named.conf) for a zone's +// update key, falling back to the object name. +func updateKeyName(ctx context.Context, c client.Client, zone *bindv1alpha1.BindZone) string { + ref := zone.Spec.UpdateKeyRef + if ref == "" { + return "" + } + var key bindv1alpha1.BindTSIGKey + if err := c.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: ref}, &key); err != nil { + return ref + } + if key.Spec.KeyName != "" { + return key.Spec.KeyName + } + return ref +} + +// matchListInline renders address-match-list entries on one line. +func matchListInline(entries []string) string { return terminateInline(entries) } + +func terminateInline(entries []string) string { + var parts []string + for _, e := range entries { + e = strings.TrimSpace(strings.TrimRight(e, ";")) + if e == "" { + continue + } + parts = append(parts, e+";") + } + return strings.Join(parts, " ") +} diff --git a/internal/controller/zone_helpers_test.go b/internal/controller/zone_helpers_test.go new file mode 100644 index 0000000..b00888c --- /dev/null +++ b/internal/controller/zone_helpers_test.go @@ -0,0 +1,79 @@ +package controller + +import ( + "testing" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" +) + +func TestFQDN(t *testing.T) { + cases := []struct{ name, zone, want string }{ + {"@", "example.com", "example.com."}, + {"", "example.com", "example.com."}, + {"www", "example.com", "www.example.com."}, + {"www.example.com.", "example.com", "www.example.com."}, + {"host", "10.in-addr.arpa", "host.10.in-addr.arpa."}, + } + for _, c := range cases { + if got := fqdn(c.name, c.zone); got != c.want { + t.Errorf("fqdn(%q,%q)=%q want %q", c.name, c.zone, got, c.want) + } + } +} + +func TestRecordsToUpdatesTTLFallback(t *testing.T) { + custom := int32(60) + records := []bindv1alpha1.Record{ + {Name: "@", Type: "A", Values: []string{"192.0.2.1"}}, + {Name: "low", Type: "A", TTL: &custom, Values: []string{"192.0.2.2"}}, + } + updates := recordsToUpdates("example.com", records, 3600) + if len(updates) != 2 { + t.Fatalf("expected 2 updates, got %d", len(updates)) + } + if updates[0].TTL != 3600 { + t.Errorf("expected default TTL 3600, got %d", updates[0].TTL) + } + if updates[1].TTL != 60 { + t.Errorf("expected record TTL 60, got %d", updates[1].TTL) + } + if updates[0].FQDN != "example.com." { + t.Errorf("apex FQDN wrong: %s", updates[0].FQDN) + } +} + +func TestRPZRulesToUpdates(t *testing.T) { + rules := []bindv1alpha1.RPZRule{ + {Trigger: "qname", Match: "bad.example.com", Action: "nxdomain"}, + {Trigger: "qname", Match: "walled.example.com", Action: "cname", Target: "block.internal"}, + } + updates := rpzRulesToUpdates("rpz.internal", rules) + if len(updates) != 2 { + t.Fatalf("expected 2 updates, got %d", len(updates)) + } + if updates[0].FQDN != "bad.example.com.rpz.internal." { + t.Errorf("qname owner wrong: %s", updates[0].FQDN) + } + if updates[0].Values[0] != "." { + t.Errorf("nxdomain rdata should be '.', got %q", updates[0].Values[0]) + } + if updates[1].Values[0] != "block.internal." { + t.Errorf("cname rdata wrong: %q", updates[1].Values[0]) + } +} + +func TestCatalogEnabledDefault(t *testing.T) { + on := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZonePrimary}} + if !catalogEnabled(on) { + t.Error("primary zone should default to catalog enabled") + } + no := false + off := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZonePrimary, Catalog: &no}} + if catalogEnabled(off) { + t.Error("catalog=false should disable membership") + } + sec := &bindv1alpha1.BindZone{Spec: bindv1alpha1.BindZoneSpec{Type: bindv1alpha1.ZoneSecondary}} + if catalogEnabled(sec) { + t.Error("secondary zone should never be a catalog member") + } +}