diff --git a/docs/architecture/private-dns/README.md b/docs/architecture/private-dns/README.md index 0bc07f24..41ff66fc 100644 --- a/docs/architecture/private-dns/README.md +++ b/docs/architecture/private-dns/README.md @@ -523,7 +523,15 @@ independent zones with the same apex. Static records have no health lease. The context directly owns its managed zone and association. DNS allocates the requested zone and exposes its reference and suffix in context status. Product -publishers use these outputs. A naming policy +publishers use these outputs. The managed zone and association carry the label +`dns.networking.miloapis.com/managed: "true"` and the well-known +`app.kubernetes.io/managed-by: dns.networking.miloapis.com`. They also get a copy of the +context labels named, comma separated, in the context's +`dns.networking.miloapis.com/managed-object-labels` annotation, so whoever +creates the context can tag what the zone is for, such as the network it +serves. The context's `kubernetes.io/description` annotation is copied too, so +clients can show what the zone is for. Copied labels and the description are +added or corrected, never removed. A naming policy adds custom names without a zone choice on each Compute or Connect resource. ```yaml diff --git a/internal/internaldns/controlplane/context_reconciler.go b/internal/internaldns/controlplane/context_reconciler.go index 85ecd06e..466083f1 100644 --- a/internal/internaldns/controlplane/context_reconciler.go +++ b/internal/internaldns/controlplane/context_reconciler.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/netip" "reflect" "sort" @@ -776,10 +777,102 @@ func (r *Reconciler) withdrawManagedZone(ctx context.Context, c *dnsv1alpha1.DNS return nil } +const ( + managedObjectLabel = "dns.networking.miloapis.com/managed" + managedObjectValue = "true" + // managedByLabel is the Kubernetes well-known label naming the manager. + managedByLabel = "app.kubernetes.io/managed-by" + managedByValue = "dns.networking.miloapis.com" + // managedLabelsAnnotation lists, comma separated, the context labels to copy + // onto the zone and association the context manages. Only listed labels + // are copied, so labels used to track the context, such as GitOps ones, + // never select the managed objects. + managedLabelsAnnotation = "dns.networking.miloapis.com/managed-object-labels" +) + +// managedObjectLabels labels the zone and association a context manages with +// the context labels it lists in managedLabelsAnnotation, so whoever creates +// the context can say what the zone is for, and marks them as managed, with +// the well-known managed-by label too. +func managedObjectLabels(c *dnsv1alpha1.DNSResolverContext) map[string]string { + l := map[string]string{} + for _, key := range strings.Split(c.Annotations[managedLabelsAnnotation], ",") { + key = strings.TrimSpace(key) + if v, ok := c.Labels[key]; ok && key != "" { + l[key] = v + } + } + l[managedObjectLabel] = managedObjectValue + l[managedByLabel] = managedByValue + return l +} + +// managedObjectAnnotations carries the context's description, if it has one. +func managedObjectAnnotations(c *dnsv1alpha1.DNSResolverContext) map[string]string { + if description, ok := c.Annotations[descriptionAnnotation]; ok { + return map[string]string{descriptionAnnotation: description} + } + return nil +} + +// descriptionAnnotation is the well-known annotation clients show as an +// object's description. A context's description is copied to its managed zone +// and association, so they say what they are for. +const descriptionAnnotation = "kubernetes.io/description" + +// syncManagedMetadata adds or corrects the copied labels and description on a +// live object the context controls, for objects created before the context +// had them. Nothing is ever removed. The metadata is informational, so a +// failed write is logged and never stops the pass. +func (r *Reconciler) syncManagedMetadata(ctx context.Context, c *dnsv1alpha1.DNSResolverContext, o client.Object) { + if !metav1.IsControlledBy(o, c) || !o.GetDeletionTimestamp().IsZero() { + return + } + objLabels, annotations := o.GetLabels(), o.GetAnnotations() + wantLabels := managedObjectLabels(c) + stale := false + for k, v := range wantLabels { + if got, ok := objLabels[k]; !ok || got != v { + stale = true + break + } + } + description, hasDescription := c.Annotations[descriptionAnnotation] + if hasDescription && annotations[descriptionAnnotation] != description { + stale = true + } + if !stale { + return + } + base := o.DeepCopyObject().(client.Object) + mergedLabels := maps.Clone(objLabels) + if mergedLabels == nil { + mergedLabels = map[string]string{} + } + maps.Copy(mergedLabels, wantLabels) + o.SetLabels(mergedLabels) + if hasDescription { + mergedAnnotations := maps.Clone(annotations) + if mergedAnnotations == nil { + mergedAnnotations = map[string]string{} + } + mergedAnnotations[descriptionAnnotation] = description + o.SetAnnotations(mergedAnnotations) + } + if listed, _ := ctx.Value(listedManagedObjectsKey{}).(*listedManagedObjects); listed != nil { + listed.changed = true + } + if err := r.Patch(ctx, o, client.MergeFrom(base)); err != nil && !apierrors.IsNotFound(err) { + o.SetLabels(objLabels) + o.SetAnnotations(annotations) + ctrl.LoggerFrom(ctx).Error(err, "label managed object", "kind", fmt.Sprintf("%T", o), "name", o.GetName()) + } +} + func (r *Reconciler) reconcileManagedZone(ctx context.Context, c *dnsv1alpha1.DNSResolverContext) (bool, error) { suffix := canonicalName(c.Spec.ManagedNamespace.DomainSuffix) zoneName := managedZoneName(c) - zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: zoneName, Namespace: c.Namespace, Labels: map[string]string{"dns.networking.miloapis.com/managed": "true"}}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: suffix, DNSZoneClassName: r.Options.PrivateZoneClassName, Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: zoneName, Namespace: c.Namespace, Labels: managedObjectLabels(c), Annotations: managedObjectAnnotations(c)}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: suffix, DNSZoneClassName: r.Options.PrivateZoneClassName, Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} if r.Scheme != nil { if err := controllerutil.SetControllerReference(c, zone, r.Scheme, controllerutil.WithBlockOwnerDeletion(false)); err != nil { return false, err @@ -788,7 +881,8 @@ func (r *Reconciler) reconcileManagedZone(ctx context.Context, c *dnsv1alpha1.DN if err := r.getOrCreate(ctx, zone); err != nil { return false, err } - association := &dnsv1alpha1.DNSZoneAssociation{ObjectMeta: metav1.ObjectMeta{Name: zoneName + "-context", Namespace: c.Namespace, Labels: map[string]string{"dns.networking.miloapis.com/managed": "true"}}, Spec: dnsv1alpha1.DNSZoneAssociationSpec{DNSZoneRef: dnsv1alpha1.DNSNameReference{Name: zone.Name}, ResolverContextRef: dnsv1alpha1.DNSNameReference{Name: c.Name}}} + r.syncManagedMetadata(ctx, c, zone) + association := &dnsv1alpha1.DNSZoneAssociation{ObjectMeta: metav1.ObjectMeta{Name: zoneName + "-context", Namespace: c.Namespace, Labels: managedObjectLabels(c), Annotations: managedObjectAnnotations(c)}, Spec: dnsv1alpha1.DNSZoneAssociationSpec{DNSZoneRef: dnsv1alpha1.DNSNameReference{Name: zone.Name}, ResolverContextRef: dnsv1alpha1.DNSNameReference{Name: c.Name}}} if r.Scheme != nil { if err := controllerutil.SetControllerReference(c, association, r.Scheme, controllerutil.WithBlockOwnerDeletion(false)); err != nil { return false, err @@ -797,6 +891,10 @@ func (r *Reconciler) reconcileManagedZone(ctx context.Context, c *dnsv1alpha1.DN if err := r.getOrCreate(ctx, association); err != nil { return false, err } + // The association webhook needs a live zone, so leave it until there is one. + if zone.DeletionTimestamp.IsZero() { + r.syncManagedMetadata(ctx, c, association) + } accepted := apimeta.FindStatusCondition(association.Status.Conditions, dnsValueAccepted) refs, err := r.readReferences(ctx, association) if err != nil { diff --git a/internal/internaldns/controlplane/managed_domain_test.go b/internal/internaldns/controlplane/managed_domain_test.go index 23604f37..7998020f 100644 --- a/internal/internaldns/controlplane/managed_domain_test.go +++ b/internal/internaldns/controlplane/managed_domain_test.go @@ -345,3 +345,68 @@ func TestAssociationInsideReservedScopesIsNotAccepted(t *testing.T) { }) } } + +func TestManagedZoneCarriesContextLabels(t *testing.T) { + s := runtime.NewScheme() + if err := addReferenceStateToScheme(s); err != nil { + t.Fatal(err) + } + resolver := &dnsv1alpha1.DNSResolverContext{ObjectMeta: metav1.ObjectMeta{Name: "vpc-a", Namespace: "project", UID: "vpc-a-context", Generation: 1}, Spec: dnsv1alpha1.DNSResolverContextSpec{ConsumerID: "project-uid/vpc-a", ManagedNamespace: dnsv1alpha1.DNSResolverContextManagedNamespace{Enabled: true, DomainSuffix: "datum.internal"}}} + c := fake.NewClientBuilder().WithScheme(s).WithObjects(resolver).WithStatusSubresource(&dnsv1alpha1.DNSResolverContext{}, &dnsv1alpha1.DNSZoneAssociation{}).WithInterceptorFuncs(interceptor.Funcs{Create: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + obj.SetUID(types.UID(obj.GetName() + "-uid")) + obj.SetGeneration(1) + return cl.Create(ctx, obj, opts...) + }}).Build() + r := &Reconciler{Client: c, Scheme: s, Options: ReconcilerOptions{ProjectUID: "project-uid", ManagedDomainSuffixes: []string{"internal"}}} + r.Options.defaults() + ctx := context.Background() + zoneKey := client.ObjectKey{Namespace: "project", Name: model.ManagedZoneName(string(resolver.UID))} + assocKey := client.ObjectKey{Namespace: "project", Name: zoneKey.Name + "-context"} + check := func(want map[string]string) { + t.Helper() + var zone dnsv1alpha1.DNSZone + var assoc dnsv1alpha1.DNSZoneAssociation + if err := c.Get(ctx, zoneKey, &zone); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, assocKey, &assoc); err != nil { + t.Fatal(err) + } + for _, got := range []map[string]string{zone.Labels, assoc.Labels} { + for k, v := range want { + if got[k] != v { + t.Fatalf("label %s = %q, want %q (labels %v)", k, got[k], v, got) + } + } + } + } + + // A zone created before the context had labels is labelled once it does. + if err := r.reconcileResolverContexts(ctx, "project", time.Now()); err != nil { + t.Fatal(err) + } + check(map[string]string{managedObjectLabel: "true", managedByLabel: managedByValue}) + if err := c.Get(ctx, client.ObjectKeyFromObject(resolver), resolver); err != nil { + t.Fatal(err) + } + resolver.Labels = map[string]string{"example.com/network-name": "vpc-a", "app.kubernetes.io/instance": "gitops", managedObjectLabel: "false"} + resolver.Annotations = map[string]string{managedLabelsAnnotation: "example.com/network-name, " + managedObjectLabel, descriptionAnnotation: "Private DNS for vpc-a"} + if err := c.Update(ctx, resolver); err != nil { + t.Fatal(err) + } + if err := r.reconcileResolverContexts(ctx, "project", time.Now()); err != nil { + t.Fatal(err) + } + check(map[string]string{"example.com/network-name": "vpc-a", managedObjectLabel: "true", managedByLabel: managedByValue}) + // Labels the context does not list are never copied. + var zone dnsv1alpha1.DNSZone + if err := c.Get(ctx, zoneKey, &zone); err != nil { + t.Fatal(err) + } + if _, ok := zone.Labels["app.kubernetes.io/instance"]; ok { + t.Fatalf("unlisted label copied: %v", zone.Labels) + } + if got := zone.Annotations[descriptionAnnotation]; got != "Private DNS for vpc-a" { + t.Fatalf("zone description = %q", got) + } +}