Initial bind-operator: 9 CRDs + controllers
Implements a Kubernetes operator that manages fleets of BIND9 servers declaratively, using controller-runtime (matching forgebot conventions). - add BindCluster reconciler: StatefulSet (pod-0 primary, secondaries), headless + client Services, rendered named.conf ConfigMap, TSIG keys Secret and rndc control Secret; watches dependent CRs to re-render - add BindTSIGKey reconciler that generates key material into a Secret - add BindZone/DNSRecord reconcilers using fully-dynamic delivery (rndc addzone + TSIG nsupdate against the primary pod) - add BindCatalogZone reconciler so secondaries auto-provision zones - add BindPolicy (RPZ), BindDNSSECPolicy, BindView, BindACL reconcilers - render primary/secondary named.conf variants selected by pod ordinal - generate CRDs, deepcopy and RBAC; add samples mapping the three Puppet roles (authoritative/resolver/external-dns) to three BindClusters - add Makefile, Dockerfile.operator, Woodpecker CI and kind manifests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user