diff --git a/internal/internaldns/controlplane/compiler.go b/internal/internaldns/controlplane/compiler.go new file mode 100644 index 0000000..d4faa7e --- /dev/null +++ b/internal/internaldns/controlplane/compiler.go @@ -0,0 +1,526 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controlplane + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/internaldns/model" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// PublicationPlan is the canonical record configuration serialized into publication chunks. +type PublicationPlan struct { + ZoneUID types.UID `json:"zoneUID"` + ZoneApex string `json:"zoneApex"` + ContextUIDs []types.UID `json:"contextUIDs"` + Owners []PublicationOwner `json:"owners"` + RRsets []PublicationRRSet `json:"rrsets"` + Contributions []ContributionFence `json:"contributions,omitempty"` + Registrations []RegistrationFence `json:"registrations,omitempty"` +} + +type PublicationOwner struct { + Name string `json:"name"` + RecordTypes []dnsv1alpha1.RRType `json:"recordTypes"` + RegistrationUID types.UID `json:"registrationUID,omitempty"` + StaticRecordUID types.UID `json:"staticRecordUID,omitempty"` +} + +type PublicationRRSet struct { + Name string `json:"name"` + RecordType dnsv1alpha1.RRType `json:"recordType"` + TTLSeconds int32 `json:"ttlSeconds"` + Records []dnsv1alpha1.RecordEntry `json:"records"` + ContributionUIDs []types.UID `json:"contributionUIDs,omitempty"` +} + +type ContributionFence struct { + UID types.UID `json:"uid"` + Generation int64 `json:"generation"` + Sequence int64 `json:"sequence"` + Retired bool `json:"retired,omitempty"` + SourceName string `json:"sourceName,omitempty"` +} + +type RegistrationFence struct { + UID types.UID `json:"uid"` + Generation int64 `json:"generation"` +} + +type CompileInput struct { + Zone *dnsv1alpha1.DNSZone + Associations []dnsv1alpha1.DNSZoneAssociation + Registrations []dnsv1alpha1.DNSRegistration + Contributions []dnsv1alpha1.DNSRecordContribution + StaticRecords []dnsv1alpha1.DNSRecordSet + Previous *PublicationPlan + Now time.Time +} + +type CompileResult struct { + Plan PublicationPlan + Accepted map[types.UID]bool + Reasons map[types.UID]string +} + +type ownershipKey struct { + name string + rrtype dnsv1alpha1.RRType +} + +// Compile builds a canonical plan and applies the same ownership and fencing +// checks even when an admission webhook was unavailable. +func Compile(in CompileInput) (CompileResult, error) { + if err := validatePrivateZone(in.Zone); err != nil { + return CompileResult{}, err + } + + result := CompileResult{Accepted: map[types.UID]bool{}, Reasons: map[types.UID]string{}} + result.Plan.ZoneUID = in.Zone.UID + result.Plan.ZoneApex = canonicalName(in.Zone.Spec.DomainName) + + result.Plan.ContextUIDs = associatedContextUIDs(in.Zone, in.Associations) + + claims := map[ownershipKey]types.UID{} + staticClaim := map[ownershipKey]types.UID{} + validStatic, staticTTLs, err := collectStaticClaims(in, result.Plan.ZoneApex, claims, staticClaim, result.Reasons) + if err != nil { + return result, err + } + + regs := map[types.UID]*dnsv1alpha1.DNSRegistration{} + for i := range in.Registrations { + reg := &in.Registrations[i] + if !reg.DeletionTimestamp.IsZero() { + result.Reasons[reg.UID] = dnsValueTerminating + continue + } + if reg.Spec.TTLSeconds < 1 || reg.Spec.TTLSeconds > dnsv1alpha1.DNSRegistrationMaxTTLSeconds { + result.Reasons[reg.UID] = "InvalidTTL" + continue + } + if !refMatches(reg.Spec.DNSZoneRef, in.Zone.Name, in.Zone.UID, in.Zone.Generation) { + continue + } + if reg.UID == "" { + result.Reasons[reg.UID] = "MissingUID" + continue + } + name := zoneOwner(reg.Spec.Name, result.Plan.ZoneApex) + valid := true + for _, rt := range reg.Spec.RecordTypes { + if model.ValidatePrivateRecordOwner(name, result.Plan.ZoneApex, string(rt)) != nil { + valid = false + break + } + } + if !valid { + result.Reasons[reg.UID] = dnsValueUnsupportedPrivateRecord + continue + } + + for _, rt := range uniqueTypes(reg.Spec.RecordTypes) { + key := ownershipKey{name, rt} + if old, ok := conflictingClaim(claims, key); ok && old != reg.UID { + result.Reasons[reg.UID] = "NameConflict" + continue + } + claims[key] = reg.UID + } + if result.Reasons[reg.UID] == "" { + regs[reg.UID] = reg + result.Accepted[reg.UID] = true + result.Plan.Registrations = append(result.Plan.Registrations, RegistrationFence{UID: reg.UID, Generation: reg.Generation}) + } + } + sort.Slice(result.Plan.Registrations, func(i, j int) bool { return result.Plan.Registrations[i].UID < result.Plan.Registrations[j].UID }) + + keys := make([]ownershipKey, 0, len(claims)) + for key := range claims { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].name == keys[j].name { + return keys[i].rrtype < keys[j].rrtype + } + return keys[i].name < keys[j].name + }) + for _, key := range keys { + owner := PublicationOwner{Name: key.name, RecordTypes: []dnsv1alpha1.RRType{key.rrtype}} + if uid, ok := staticClaim[key]; ok { + owner.StaticRecordUID = uid + } else { + owner.RegistrationUID = claims[key] + } + result.Plan.Owners = append(result.Plan.Owners, owner) + } + + previousSeq := map[types.UID]ContributionFence{} + if in.Previous != nil { + for _, f := range in.Previous.Contributions { + previousSeq[f.UID] = f + } + } + // Publication fences are lifetime high-water marks. Keep fences for absent, + // retired, invalid, and rolled-back observations so a later reconcile or + // controller takeover cannot resurrect an older contribution. + publicationFences := make(map[types.UID]ContributionFence, len(previousSeq)) + currentContributions := map[types.UID]bool{} + for _, c := range in.Contributions { + currentContributions[c.UID] = true + } + for uid, fence := range previousSeq { + if !currentContributions[uid] { + fence.Retired = true + } + publicationFences[uid] = fence + } + type aggregate struct { + ttl int32 + records []dnsv1alpha1.RecordEntry + uids []types.UID + } + aggregates := map[ownershipKey]*aggregate{} + for i := range in.Contributions { + c := &in.Contributions[i] + reg := regs[c.Spec.RegistrationRef.UID] + reason := validateContribution(c, reg, previousSeq[c.UID]) + if reason != "" { + result.Reasons[c.UID] = reason + continue + } + currentFence := ContributionFence{UID: c.UID, Generation: c.Generation, Sequence: c.Generation, Retired: !c.DeletionTimestamp.IsZero(), SourceName: c.Name} + previousFence := previousSeq[c.UID] + if previousFence.UID == "" || currentFence.Generation > previousFence.Generation || currentFence.Retired { + publicationFences[c.UID] = currentFence + } + if !c.DeletionTimestamp.IsZero() { + result.Reasons[c.UID] = dnsValueTerminating + continue + } + if !c.Spec.Eligible { + result.Reasons[c.UID] = "Ineligible" + continue + } + validBundle := true + for _, set := range c.Spec.RecordSets { + if len(set.Records) == 0 || !containsType(reg.Spec.RecordTypes, set.RecordType) { + validBundle = false + break + } + for _, record := range set.Records { + key := ownershipKey{zoneOwner(record.Name, result.Plan.ZoneApex), set.RecordType} + if model.ValidatePrivateRecordOwner(key.name, result.Plan.ZoneApex, string(set.RecordType)) != nil || !registrationOwns(reg, key, result.Plan.ZoneApex, claims) || !recordAllowed(reg, key, result.Plan.ZoneApex) { + validBundle = false + break + } + if _, err := recordContent(set.RecordType, record); err != nil { + validBundle = false + break + } + } + if !validBundle { + break + } + } + if !validBundle { + result.Reasons[c.UID] = "NameOrTypeOutsideRegistration" + continue + } + result.Accepted[c.UID] = true + for _, set := range c.Spec.RecordSets { + for _, record := range set.Records { + key := ownershipKey{zoneOwner(record.Name, result.Plan.ZoneApex), set.RecordType} + a := aggregates[key] + if a == nil { + a = &aggregate{ttl: reg.Spec.TTLSeconds} + aggregates[key] = a + } + record.Name = key.name + a.records = append(a.records, record) + a.uids = append(a.uids, c.UID) + } + } + } + result.Plan.Contributions = make([]ContributionFence, 0, len(publicationFences)) + for _, fence := range publicationFences { + result.Plan.Contributions = append(result.Plan.Contributions, fence) + } + + for i := range in.StaticRecords { + rs := &in.StaticRecords[i] + if rs.Spec.DNSZoneRef.Name != in.Zone.Name || !validStatic[rs.UID] { + continue + } + for _, record := range rs.Spec.Records { + key := ownershipKey{zoneOwner(record.Name, result.Plan.ZoneApex), rs.Spec.RecordType} + a := aggregates[key] + if a == nil { + a = &aggregate{ttl: staticTTLs[key]} + aggregates[key] = a + } + record.Name = key.name + a.records = append(a.records, record) + } + } + for key, a := range aggregates { + sortAggregate(a.records, a.uids) + result.Plan.RRsets = append(result.Plan.RRsets, PublicationRRSet{Name: key.name, RecordType: key.rrtype, TTLSeconds: a.ttl, Records: a.records, ContributionUIDs: a.uids}) + } + sort.Slice(result.Plan.RRsets, func(i, j int) bool { + if result.Plan.RRsets[i].Name == result.Plan.RRsets[j].Name { + return result.Plan.RRsets[i].RecordType < result.Plan.RRsets[j].RecordType + } + return result.Plan.RRsets[i].Name < result.Plan.RRsets[j].Name + }) + sort.Slice(result.Plan.Contributions, func(i, j int) bool { return result.Plan.Contributions[i].UID < result.Plan.Contributions[j].UID }) + if err := validateSRVTargets(result.Plan.RRsets, result.Plan.ZoneApex); err != nil { + return result, err + } + return result, nil +} + +func validateContribution(c *dnsv1alpha1.DNSRecordContribution, reg *dnsv1alpha1.DNSRegistration, previous ContributionFence) string { + if reg == nil || c.Spec.RegistrationRef.Name != reg.Name || c.Spec.RegistrationRef.Generation != reg.Generation { + return dnsValueStaleRegistrationGeneration + } + if previous.Retired { + return "RetiredContribution" + } + if c.Generation < 1 { + return "InvalidContributionGeneration" + } + if previous.UID != "" { + if c.Generation < previous.Generation { + return "StaleContributionGeneration" + } + } + return "" +} + +func recordAllowed(reg *dnsv1alpha1.DNSRegistration, key ownershipKey, apex string) bool { + if !containsType(reg.Spec.RecordTypes, key.rrtype) { + return false + } + name := zoneOwner(reg.Spec.Name, apex) + allowedName := key.name == name + for _, scope := range reg.Spec.ReservedDescendants { + s := zoneOwner(scope, apex) + if key.name == s || strings.HasSuffix(key.name, "."+s) { + allowedName = true + } + } + if !allowedName { + return false + } + return allowedName +} + +func validateSRVTargets(rrsets []PublicationRRSet, apex string) error { + addresses := map[string]bool{} + for _, r := range rrsets { + if r.RecordType == dnsv1alpha1.RRTypeA || r.RecordType == dnsv1alpha1.RRTypeAAAA { + addresses[absoluteOwner(r.Name, apex)] = true + } + } + for _, r := range rrsets { + if r.RecordType != dnsv1alpha1.RRTypeSRV { + continue + } + for _, entry := range r.Records { + if entry.SRV != nil && !addresses[srvTargetOwner(entry.SRV.Target, apex)] { + return fmt.Errorf("SRV target %s has no address in publication", entry.SRV.Target) + } + } + } + return nil +} + +func srvTargetOwner(name, apex string) string { + if strings.HasSuffix(strings.TrimSpace(name), ".") { + return canonicalName(name) + } + return absoluteOwner(name, apex) +} + +func zoneOwner(s, apex string) string { + n := canonicalName(s) + a := canonicalName(apex) + if n == a { + return "@" + } + if strings.HasSuffix(n, "."+a) { + return strings.TrimSuffix(n, "."+a) + } + return n +} + +func conflictingClaim(claims map[ownershipKey]types.UID, key ownershipKey) (types.UID, bool) { + if uid, ok := claims[key]; ok { + return uid, true + } + if key.rrtype == dnsv1alpha1.RRTypeCNAME { + for k, uid := range claims { + if k.name == key.name { + return uid, true + } + } + } else if uid, ok := claims[ownershipKey{key.name, dnsv1alpha1.RRTypeCNAME}]; ok { + return uid, true + } + return "", false +} + +func registrationOwns(reg *dnsv1alpha1.DNSRegistration, key ownershipKey, apex string, claims map[ownershipKey]types.UID) bool { + if uid, ok := conflictingClaim(claims, key); ok && uid != reg.UID { + return false + } + if key.name == zoneOwner(reg.Spec.Name, apex) { + return true + } + for _, scope := range reg.Spec.ReservedDescendants { + s := zoneOwner(scope, apex) + if key.name == s || strings.HasSuffix(key.name, "."+s) { + return true + } + } + return false +} + +func containsType(xs []dnsv1alpha1.RRType, x dnsv1alpha1.RRType) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} + +func uniqueTypes(xs []dnsv1alpha1.RRType) []dnsv1alpha1.RRType { + m := map[dnsv1alpha1.RRType]bool{} + var out []dnsv1alpha1.RRType + for _, x := range xs { + if !m[x] { + m[x] = true + out = append(out, x) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func sortRecords(records []dnsv1alpha1.RecordEntry) { + sort.Slice(records, func(i, j int) bool { + a, _ := json.Marshal(records[i]) + b, _ := json.Marshal(records[j]) + return string(a) < string(b) + }) +} + +func sortAggregate(records []dnsv1alpha1.RecordEntry, uids []types.UID) { + if len(uids) != len(records) { + sortRecords(records) + return + } + type pair struct { + record dnsv1alpha1.RecordEntry + uid types.UID + key string + } + pairs := make([]pair, len(records)) + for i := range records { + b, _ := json.Marshal(records[i]) + pairs[i] = pair{records[i], uids[i], string(b) + "\x00" + string(uids[i])} + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].key < pairs[j].key }) + for i := range pairs { + records[i] = pairs[i].record + uids[i] = pairs[i].uid + } +} + +func associatedContextUIDs(zone *dnsv1alpha1.DNSZone, associations []dnsv1alpha1.DNSZoneAssociation) []types.UID { + contextsByUID := map[types.UID]struct{}{} + for i := range associations { + a := &associations[i] + accepted := apimeta.FindStatusCondition(a.Status.Conditions, "Accepted") + consumerUID := a.Status.ResolvedResolverContextRef.UID + if a.DeletionTimestamp.IsZero() && accepted != nil && accepted.Status == metav1.ConditionTrue && refMatches(a.Status.ResolvedDNSZoneRef, zone.Name, zone.UID, zone.Generation) && consumerUID != "" { + contextsByUID[consumerUID] = struct{}{} + } + } + if len(contextsByUID) == 0 { + return nil + } + contexts := make([]types.UID, 0, len(contextsByUID)) + for uid := range contextsByUID { + contexts = append(contexts, uid) + } + sort.Slice(contexts, func(i, j int) bool { return contexts[i] < contexts[j] }) + + return contexts +} + +func validatePrivateZone(zone *dnsv1alpha1.DNSZone) error { + if zone == nil || zone.UID == "" { + return fmt.Errorf("zone and API-assigned UID are required") + } + if zone.Spec.Visibility != dnsv1alpha1.DNSZoneVisibilityPrivate { + return fmt.Errorf("zone %s is not private", zone.Name) + } + return nil +} + +func collectStaticClaims(in CompileInput, apex string, claims, staticClaim map[ownershipKey]types.UID, reasons map[types.UID]string) (map[types.UID]bool, map[ownershipKey]int32, error) { + validStatic := map[types.UID]bool{} + staticTTLs := map[ownershipKey]int32{} + for i := range in.StaticRecords { + rs := &in.StaticRecords[i] + if rs.Spec.DNSZoneRef.Name != in.Zone.Name || !rs.DeletionTimestamp.IsZero() { + continue + } + valid := true + for _, record := range rs.Spec.Records { + if model.ValidatePrivateRecordOwner(zoneOwner(record.Name, apex), apex, string(rs.Spec.RecordType)) != nil { + valid = false + break + } + } + if !valid { + reasons[rs.UID] = dnsValueUnsupportedPrivateRecord + continue + } + for _, record := range rs.Spec.Records { + ttl := int64(300) + if record.TTL != nil { + ttl = *record.TTL + } + if ttl < 0 || ttl > 2147483647 { + return nil, nil, fmt.Errorf("static TTL must be between 0 and 2147483647 seconds") + } + key := ownershipKey{zoneOwner(record.Name, apex), rs.Spec.RecordType} + if previous, ok := staticTTLs[key]; ok && previous != int32(ttl) { + return nil, nil, fmt.Errorf("conflicting static TTLs for %s %s", key.name, key.rrtype) + } + staticTTLs[key] = int32(ttl) + } + validStatic[rs.UID] = true + for _, record := range rs.Spec.Records { + key := ownershipKey{zoneOwner(record.Name, apex), rs.Spec.RecordType} + if old, ok := claims[key]; ok && old != rs.UID { + return nil, nil, fmt.Errorf("static ownership conflict for %s %s", key.name, key.rrtype) + } + claims[key], staticClaim[key] = rs.UID, rs.UID + } + } + + return validStatic, staticTTLs, nil +} diff --git a/internal/internaldns/controlplane/compiler_test.go b/internal/internaldns/controlplane/compiler_test.go new file mode 100644 index 0000000..76449f4 --- /dev/null +++ b/internal/internaldns/controlplane/compiler_test.go @@ -0,0 +1,319 @@ +package controlplane + +import ( + "encoding/json" + "strings" + "testing" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestCompileDeterministicMultiVPCAggregation(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + in := fixture(now) + first, err := Compile(in) + if err != nil { + t.Fatal(err) + } + in.Associations[0], in.Associations[1] = in.Associations[1], in.Associations[0] + in.Contributions = append([]dnsv1alpha1.DNSRecordContribution(nil), in.Contributions...) + second, err := Compile(in) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(first.Plan) + b, _ := json.Marshal(second.Plan) + if string(a) != string(b) { + t.Fatalf("plan depends on input order:\n%s\n%s", a, b) + } + if len(first.Plan.ContextUIDs) != 2 || first.Plan.ContextUIDs[0] != "vpc-a" || first.Plan.ContextUIDs[1] != "vpc-b" { + t.Fatalf("unexpected VPCs: %v", first.Plan.ContextUIDs) + } + wire, err := WirePlan(first.Plan) + if err != nil { + t.Fatal(err) + } + if len(wire.RRSets) != 1 || len(wire.RRSets[0].Records) != 1 { + t.Fatalf("unexpected wire plan: %#v", wire) + } + record := wire.RRSets[0].Records[0] + if record.ContributionUID != "contribution-a" || record.Generation != 18 || record.Sequence != 18 || !record.Eligible { + t.Fatalf("missing contribution fence: %#v", record) + } +} + +func TestCompileRejectsStaleGenerationAndWholeInvalidBundle(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + t.Run("stale registration", func(t *testing.T) { + in := fixture(now) + in.Contributions[0].Spec.RegistrationRef.Generation-- + got, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if got.Reasons["contribution-a"] != "StaleRegistrationGeneration" || len(got.Plan.RRsets) != 0 { + t.Fatalf("stale contribution published: %#v", got) + } + }) + t.Run("bundle atomic", func(t *testing.T) { + in := fixture(now) + in.Registrations[0].Spec.ReservedDescendants = []string{"targets"} + in.Contributions[0].Spec.RecordSets[0].Records = append(in.Contributions[0].Spec.RecordSets[0].Records, dnsv1alpha1.RecordEntry{Name: "other", AAAA: &dnsv1alpha1.AAAARecordSpec{Content: "fd00::2"}}) + got, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if got.Reasons["contribution-a"] != "NameOrTypeOutsideRegistration" || len(got.Plan.RRsets) != 0 { + t.Fatalf("partial invalid bundle published: %#v", got.Plan.RRsets) + } + if len(got.Plan.Contributions) != 1 || got.Plan.Contributions[0].Sequence != 18 { + t.Fatalf("rejected observation did not advance fence: %#v", got.Plan.Contributions) + } + }) +} + +func TestCompileWithdrawalAdvancesHighWaterAndRejectsRollback(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + in := fixture(now) + previous := in.Contributions[0] + in.Previous = &PublicationPlan{Contributions: []ContributionFence{{UID: previous.UID, Generation: previous.Generation, Sequence: 18}}} + in.Contributions[0].Generation = 19 + in.Contributions[0].Spec.Eligible = false + got, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if got.Reasons[previous.UID] != "Ineligible" || len(got.Plan.RRsets) != 0 || got.Plan.Contributions[0].Sequence != 19 { + t.Fatalf("withdrawal fence incorrect: %#v", got) + } + in.Previous = &got.Plan + in.Contributions[0].Generation = 18 + in.Contributions[0].Spec.Eligible = true + rolled, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if rolled.Reasons[previous.UID] != "StaleContributionGeneration" || len(rolled.Plan.RRsets) != 0 { + t.Fatalf("generation rollback resurrected records: %#v", rolled) + } +} + +func TestCompileRetainsHighWaterAcrossRejectedPublications(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + firstInput := fixture(now) + first, err := Compile(firstInput) + if err != nil { + t.Fatal(err) + } + if len(first.Plan.Contributions) != 1 || first.Plan.Contributions[0].Sequence != 18 { + t.Fatalf("initial high-water missing: %#v", first.Plan.Contributions) + } + + rollbackInput := fixture(now) + rollbackInput.Previous = &first.Plan + rollbackInput.Contributions[0].Generation = 17 + rollback, err := Compile(rollbackInput) + if err != nil { + t.Fatal(err) + } + if rollback.Reasons["contribution-a"] != "StaleContributionGeneration" || len(rollback.Plan.RRsets) != 0 { + t.Fatalf("rollback was not rejected: %#v", rollback) + } + if len(rollback.Plan.Contributions) != 1 || rollback.Plan.Contributions[0].Sequence != 18 { + t.Fatalf("rejected publication erased high-water: %#v", rollback.Plan.Contributions) + } + + retryInput := fixture(now) + retryInput.Previous = &rollback.Plan + retryInput.Contributions[0].Generation = 17 + retry, err := Compile(retryInput) + if err != nil { + t.Fatal(err) + } + if retry.Reasons["contribution-a"] != "StaleContributionGeneration" || len(retry.Plan.RRsets) != 0 || retry.Plan.Contributions[0].Sequence != 18 { + t.Fatalf("second reconcile resurrected rolled-back data: %#v", retry) + } +} + +func TestCompileNormalizesFQDNAndEnforcesCNAMEExclusivity(t *testing.T) { + in := fixture(time.Now().UTC()) + in.StaticRecords = []dnsv1alpha1.DNSRecordSet{{ + ObjectMeta: metav1.ObjectMeta{Name: "static", UID: "static-a"}, + Spec: dnsv1alpha1.DNSRecordSetSpec{ + DNSZoneRef: structLocal("private"), RecordType: dnsv1alpha1.RRTypeA, + Records: []dnsv1alpha1.RecordEntry{{Name: "api.corp.internal", A: &dnsv1alpha1.ARecordSpec{Content: "192.0.2.1"}}}, + }, + }} + in.Registrations[0].Spec.Name = "api" + in.Registrations[0].Spec.RecordTypes = []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeCNAME} + got, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if got.Reasons["registration-a"] != "NameConflict" { + t.Fatalf("CNAME coexistence accepted: %#v", got.Reasons) + } +} + +func TestCompileSRVTargetUsesAbsoluteZoneIdentity(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + in := fixture(now) + in.Registrations[0].Spec.Name = "_grpc._tcp.svc" + in.Registrations[0].Spec.ReservedDescendants = []string{"vm1.instances"} + in.Registrations[0].Spec.RecordTypes = []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeAAAA, dnsv1alpha1.RRTypeSRV} + in.Contributions[0].Spec.RecordSets = []dnsv1alpha1.DNSContributionRecordSet{ + {RecordType: dnsv1alpha1.RRTypeAAAA, Records: []dnsv1alpha1.RecordEntry{{Name: "vm1.instances", AAAA: &dnsv1alpha1.AAAARecordSpec{Content: "fd00::1"}}}}, + {RecordType: dnsv1alpha1.RRTypeSRV, Records: []dnsv1alpha1.RecordEntry{{Name: "_grpc._tcp.svc", SRV: &dnsv1alpha1.SRVRecordSpec{Priority: 10, Weight: 20, Port: 8443, Target: "vm1.instances.corp.internal."}}}}, + } + got, err := Compile(in) + if err != nil { + t.Fatalf("absolute in-zone SRV target rejected: %v", err) + } + if len(got.Plan.RRsets) != 2 { + t.Fatalf("SRV bundle was not published: %#v", got) + } + + in.Contributions[0].Spec.RecordSets = in.Contributions[0].Spec.RecordSets[1:] + if _, err := Compile(in); err == nil || !strings.Contains(err.Error(), "has no address") { + t.Fatalf("missing SRV target address was not rejected: %v", err) + } +} + +func fixture(now time.Time) CompileInput { + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "private", UID: "zone-a", Generation: 2}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "corp.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + assoc := func(name string, vpc types.UID) dnsv1alpha1.DNSZoneAssociation { + return dnsv1alpha1.DNSZoneAssociation{ObjectMeta: metav1.ObjectMeta{Name: name, UID: types.UID(name)}, Spec: dnsv1alpha1.DNSZoneAssociationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID}, ResolverContextRef: dnsv1alpha1.DNSObjectReference{Name: name, UID: vpc}}, Status: dnsv1alpha1.DNSZoneAssociationStatus{ResolvedDNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID, Generation: zone.Generation}, ResolvedResolverContextRef: dnsv1alpha1.DNSObjectReference{Name: name, UID: vpc}, Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue}}}} + } + reg := dnsv1alpha1.DNSRegistration{ObjectMeta: metav1.ObjectMeta{Name: "api", UID: "registration-a", Generation: 3}, Spec: dnsv1alpha1.DNSRegistrationSpec{DNSZoneRef: dnsv1alpha1.DNSObjectReference{Name: zone.Name, UID: zone.UID, Generation: zone.Generation}, Name: "api", RecordTypes: []dnsv1alpha1.RRType{dnsv1alpha1.RRTypeAAAA}, TTLSeconds: 30}} + c := dnsv1alpha1.DNSRecordContribution{ObjectMeta: metav1.ObjectMeta{Name: "endpoint-a", UID: "contribution-a", Generation: 18}, Spec: dnsv1alpha1.DNSRecordContributionSpec{RegistrationRef: dnsv1alpha1.DNSObjectReference{Name: reg.Name, UID: reg.UID, Generation: reg.Generation}, RecordSets: []dnsv1alpha1.DNSContributionRecordSet{{RecordType: dnsv1alpha1.RRTypeAAAA, Records: []dnsv1alpha1.RecordEntry{{Name: "api.corp.internal", AAAA: &dnsv1alpha1.AAAARecordSpec{Content: "fd00::1"}}}}}, Eligible: true}, Status: dnsv1alpha1.DNSRecordContributionStatus{ObservedGeneration: 4}} + return CompileInput{Zone: zone, Associations: []dnsv1alpha1.DNSZoneAssociation{assoc("b", "vpc-b"), assoc("a", "vpc-a")}, Registrations: []dnsv1alpha1.DNSRegistration{reg}, Contributions: []dnsv1alpha1.DNSRecordContribution{c}, Now: now} +} + +func structLocal(name string) corev1.LocalObjectReference { + return corev1.LocalObjectReference{Name: name} +} + +func TestCompileRejectsPrivateAuthorityOverridesWithoutBlockingHealthyRecords(t *testing.T) { + for _, tt := range []struct { + name string + recordType dnsv1alpha1.RRType + entry dnsv1alpha1.RecordEntry + }{ + {"SOA", dnsv1alpha1.RRTypeSOA, dnsv1alpha1.RecordEntry{Name: "@", SOA: &dnsv1alpha1.SOARecordSpec{MName: "ns.corp.internal.", RName: "hostmaster.corp.internal.", Serial: 1}}}, + {"apex CNAME", dnsv1alpha1.RRTypeCNAME, dnsv1alpha1.RecordEntry{Name: "CORP.INTERNAL.", CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "target.example."}}}, + } { + t.Run(tt.name, func(t *testing.T) { + in := fixture(time.Now().UTC()) + in.StaticRecords = []dnsv1alpha1.DNSRecordSet{{ObjectMeta: metav1.ObjectMeta{Name: "invalid", UID: "static-invalid"}, Spec: dnsv1alpha1.DNSRecordSetSpec{DNSZoneRef: structLocal(in.Zone.Name), RecordType: tt.recordType, Records: []dnsv1alpha1.RecordEntry{tt.entry}}}} + bad := in.Registrations[0].DeepCopy() + bad.Name = "invalid-registration" + bad.UID = "invalid-registration-uid" + bad.Spec.Name = tt.entry.Name + bad.Spec.RecordTypes = []dnsv1alpha1.RRType{tt.recordType} + in.Registrations = append(in.Registrations, *bad) + got, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if got.Reasons["static-invalid"] != "UnsupportedPrivateRecord" || got.Reasons[bad.UID] != "UnsupportedPrivateRecord" { + t.Fatalf("authority override not rejected: %#v", got.Reasons) + } + if len(got.Plan.RRsets) != 1 || got.Plan.RRsets[0].RecordType != dnsv1alpha1.RRTypeAAAA || !got.Accepted["contribution-a"] { + t.Fatalf("healthy publication was blocked: %#v", got) + } + wire, err := WirePlan(got.Plan) + if err != nil { + t.Fatal(err) + } + if err = wire.Validate(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestStaticTTLPreservedAndConflictsRejected(t *testing.T) { + now := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + in := fixture(now) + in.Registrations = nil + in.Contributions = nil + ttl := int64(30) + in.StaticRecords = []dnsv1alpha1.DNSRecordSet{{ObjectMeta: metav1.ObjectMeta{Name: "static", UID: "static"}, Spec: dnsv1alpha1.DNSRecordSetSpec{DNSZoneRef: corev1.LocalObjectReference{Name: in.Zone.Name}, RecordType: dnsv1alpha1.RRTypeA, Records: []dnsv1alpha1.RecordEntry{{Name: "api", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "192.0.2.1"}}}}}} + result, err := Compile(in) + if err != nil { + t.Fatal(err) + } + wire, err := WirePlan(result.Plan) + if err != nil { + t.Fatal(err) + } + if wire.RRSets[0].TTL != 30 { + t.Fatalf("TTL=%d", wire.RRSets[0].TTL) + } + ttl = 0 + result, err = Compile(in) + if err != nil { + t.Fatal(err) + } + if result.Plan.RRsets[0].TTLSeconds != 0 { + t.Fatal("explicit zero TTL was discarded") + } + ttl = 30 + stamp := metav1.NewTime(now) + in.StaticRecords[0].DeletionTimestamp = &stamp + result, err = Compile(in) + if err != nil || len(result.Plan.RRsets) != 0 { + t.Fatalf("terminating static records retained: %#v %v", result, err) + } + in.StaticRecords[0].DeletionTimestamp = nil + otherTTL := int64(31) + in.StaticRecords[0].Spec.Records = append(in.StaticRecords[0].Spec.Records, dnsv1alpha1.RecordEntry{Name: "api", TTL: &otherTTL, A: &dnsv1alpha1.ARecordSpec{Content: "192.0.2.2"}}) + if _, err := Compile(in); err == nil { + t.Fatal("conflicting per-RRset TTL accepted") + } +} + +func TestCompileRejectsUnboundedDynamicTTL(t *testing.T) { + in := fixture(time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)) + in.Registrations[0].Spec.TTLSeconds = 2147483647 + result, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if result.Accepted[in.Registrations[0].UID] || len(result.Plan.RRsets) != 0 { + t.Fatal("unbounded TTL published") + } +} + +func TestCompileRetainsHealthyRecordsUntilExplicitWithdrawal(t *testing.T) { + now := time.Now().UTC() + input := fixture(now) + first, err := Compile(input) + if err != nil { + t.Fatal(err) + } + input.Now = now.Add(365 * 24 * time.Hour) + input.Previous = &first.Plan + after, err := Compile(input) + if err != nil { + t.Fatal(err) + } + beforeJSON, _ := json.Marshal(first.Plan) + afterJSON, _ := json.Marshal(after.Plan) + if string(beforeJSON) != string(afterJSON) { + t.Fatal("elapsed time changed the last reported endpoint state") + } + input.Contributions[0].Spec.Eligible = false + input.Contributions[0].Generation++ + withdrawn, err := Compile(input) + if err != nil { + t.Fatal(err) + } + if len(withdrawn.Plan.RRsets) != 0 { + t.Fatal("explicit unhealthy observation did not withdraw the endpoint") + } +} diff --git a/internal/internaldns/controlplane/contribution_lifetime_test.go b/internal/internaldns/controlplane/contribution_lifetime_test.go new file mode 100644 index 0000000..74e7e4a --- /dev/null +++ b/internal/internaldns/controlplane/contribution_lifetime_test.go @@ -0,0 +1,163 @@ +package controlplane + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/internaldns/model" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestPublicationReadinessPreservesCurrentCompilerStatus(t *testing.T) { + input := fixture(time.Now()) + zone, registration, contribution := input.Zone, input.Registrations[0], input.Contributions[0] + zone.Namespace, registration.Namespace, contribution.Namespace = "project", "project", "project" + owner := &dnsv1alpha1.DNSPublicationOwnership{ObjectMeta: metav1.ObjectMeta{Name: "owner-" + model.OpaqueToken(string(zone.UID))[:20], Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ZoneUID: zone.UID, WriterEpoch: 1, ActiveManifestName: "active"}} + manifest := &dnsv1alpha1.DNSPublicationManifest{ObjectMeta: metav1.ObjectMeta{Name: "active", Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationManifestSpec{WriterEpoch: 1, Revision: 2, RegistrationFences: []dnsv1alpha1.DNSRegistrationFence{{UID: registration.UID, Generation: registration.Generation}}, ContributionFences: []dnsv1alpha1.DNSContributionFence{{UID: contribution.UID, Generation: contribution.Generation, Sequence: contribution.Generation}}}, Status: dnsv1alpha1.DNSPublicationManifestStatus{Conditions: []metav1.Condition{{Type: "Published", Status: metav1.ConditionTrue}}}} + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owner, manifest, ®istration, &contribution).WithStatusSubresource(&dnsv1alpha1.DNSRegistration{}, &dnsv1alpha1.DNSRecordContribution{}).Build() + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(&contribution), &contribution); err != nil { + t.Fatal(err) + } + stale := *contribution.DeepCopy() + contribution.Status.ObservedGeneration = contribution.Generation + contribution.Status.Conditions = []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, ObservedGeneration: contribution.Generation, Reason: "Accepted", LastTransitionTime: metav1.Now()}} + if err := cl.Status().Update(context.Background(), &contribution); err != nil { + t.Fatal(err) + } + r := &Reconciler{Client: cl, Options: ReconcilerOptions{PlatformNamespace: "platform"}} + if err := r.aggregatePublicationStatus(context.Background(), zone, []dnsv1alpha1.DNSRegistration{registration}, []dnsv1alpha1.DNSRecordContribution{stale}, time.Now()); err != nil { + t.Fatal(err) + } + if err := cl.Get(context.Background(), client.ObjectKeyFromObject(&contribution), &contribution); err != nil { + t.Fatal(err) + } + accepted, published := apimeta.FindStatusCondition(contribution.Status.Conditions, "Accepted"), apimeta.FindStatusCondition(contribution.Status.Conditions, "Published") + if accepted == nil || accepted.Status != metav1.ConditionTrue || published == nil || published.Status != metav1.ConditionTrue || contribution.Status.ObservedGeneration != contribution.Generation || contribution.Status.PublishedRevision != 2 { + t.Fatalf("readiness lost current compiler output: %#v", contribution.Status) + } +} + +func TestContributionLifetimesSurvivePublicationTakeover(t *testing.T) { + input := fixture(time.Now()) + contribution := input.Contributions[0] + contribution.Namespace = "project" + original := PublicationPlan{ZoneUID: input.Zone.UID, ZoneApex: input.Zone.Spec.DomainName, Contributions: []ContributionFence{ + {UID: contribution.UID, Generation: contribution.Generation, Sequence: contribution.Generation, SourceName: contribution.Name}, + {UID: "retired-endpoint", Generation: 1, Sequence: 1, SourceName: "deleted", Retired: true}, + }} + wire, err := WirePlan(original) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + chunk := &dnsv1alpha1.DNSPublicationChunk{ObjectMeta: metav1.ObjectMeta{Name: "committed-000", Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationChunkSpec{WriterEpoch: 1, Revision: 1, Index: 0, SHA256: model.Hash(payload), Payload: payload}} + manifest := &dnsv1alpha1.DNSPublicationManifest{ObjectMeta: metav1.ObjectMeta{Name: "committed", Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationManifestSpec{ZoneRef: dnsv1alpha1.DNSObjectReference{UID: input.Zone.UID}, WriterEpoch: 1, Revision: 1, ContentHash: model.Hash(payload), Chunks: []dnsv1alpha1.DNSPublicationChunkReference{{Name: chunk.Name, SHA256: chunk.Spec.SHA256, Size: int32(len(payload))}}}} + owner := &dnsv1alpha1.DNSPublicationOwnership{ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ZoneUID: input.Zone.UID, WriterEpoch: 2, ActiveManifestName: manifest.Name}} + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(chunk, manifest, owner, &contribution).Build() + r := &Reconciler{Client: cl, Options: ReconcilerOptions{PlatformNamespace: "platform"}} + restored, err := r.loadPrevious(context.Background(), owner) + if err != nil { + t.Fatal(err) + } + if len(restored.Contributions) != 2 || restored.Contributions[0].SourceName != contribution.Name || restored.Contributions[1].SourceName != "deleted" || !restored.Contributions[1].Retired { + t.Fatalf("lifetime metadata lost in committed snapshot: %#v", restored.Contributions) + } + confirmed, err := r.confirmContributions(context.Background(), "project", nil, restored) + if err != nil || len(confirmed) != 1 { + t.Fatalf("takeover could not confirm live source: %#v, %v", confirmed, err) + } + input.Previous, input.Contributions = restored, confirmed + compiled, err := Compile(input) + if err != nil { + t.Fatal(err) + } + if len(compiled.Plan.RRsets) != 1 || len(compiled.Plan.Contributions) != 2 || !compiled.Plan.Contributions[1].Retired { + t.Fatalf("takeover lost live records or retirement: %#v", compiled.Plan) + } +} + +func TestDeletedContributionCannotReturnThroughReplay(t *testing.T) { + in := fixture(time.Now()) + live, err := Compile(in) + if err != nil { + t.Fatal(err) + } + in.Previous = &live.Plan + in.Contributions = nil + deleted, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if len(deleted.Plan.Contributions) != 1 || !deleted.Plan.Contributions[0].Retired { + t.Fatal("deleted lifetime was not retained") + } + for _, generation := range []int64{18, 19} { + replayed := fixture(time.Now()) + replayed.Previous = &deleted.Plan + replayed.Contributions[0].Generation = generation + got, err := Compile(replayed) + if err != nil { + t.Fatal(err) + } + if got.Reasons["contribution-a"] != "RetiredContribution" || len(got.Plan.RRsets) != 0 { + t.Fatalf("retired lifetime returned: %#v", got) + } + } + recreated := fixture(time.Now()) + recreated.Previous = &deleted.Plan + recreated.Contributions[0].UID = "replacement-contribution" + got, err := Compile(recreated) + if err != nil { + t.Fatal(err) + } + if len(got.Plan.RRsets) != 1 || !got.Accepted["replacement-contribution"] { + t.Fatalf("replacement lifetime did not publish: %#v", got) + } +} + +func TestContributionAbsenceRequiresAuthoritativeRead(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + contribution := &dnsv1alpha1.DNSRecordContribution{ObjectMeta: metav1.ObjectMeta{Name: "endpoint", Namespace: "project", UID: "lifetime", Generation: 1}} + previous := &PublicationPlan{Contributions: []ContributionFence{{UID: contribution.UID, SourceName: contribution.Name, Generation: 1, Sequence: 1}}} + cache := fake.NewClientBuilder().WithScheme(scheme).Build() + authoritative := fake.NewClientBuilder().WithScheme(scheme).WithObjects(contribution).Build() + r := &Reconciler{Client: cache, APIReader: authoritative} + confirmed, err := r.confirmContributions(context.Background(), "project", nil, previous) + if err != nil || len(confirmed) != 1 || confirmed[0].UID != contribution.UID { + t.Fatalf("cache omission lost live desired state: %#v, %v", confirmed, err) + } + r.APIReader = fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return fmt.Errorf("source API unavailable") + }}).Build() + if _, err := r.confirmContributions(context.Background(), "project", nil, previous); err == nil { + t.Fatal("source read error was treated as deletion") + } + r.APIReader = cache + confirmed, err = r.confirmContributions(context.Background(), "project", []dnsv1alpha1.DNSRecordContribution{*contribution}, previous) + if err != nil || len(confirmed) != 0 { + t.Fatalf("authoritative deletion retained stale cached source: %#v, %v", confirmed, err) + } +} diff --git a/internal/internaldns/controlplane/publication_helpers.go b/internal/internaldns/controlplane/publication_helpers.go new file mode 100644 index 0000000..93c5a58 --- /dev/null +++ b/internal/internaldns/controlplane/publication_helpers.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controlplane + +import ( + "crypto/sha256" + "fmt" + "strings" + + "go.miloapis.com/dns-operator/internal/internaldns/model" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func boolCondition(v bool) metav1.ConditionStatus { + if v { + return metav1.ConditionTrue + } + return metav1.ConditionFalse +} + +func regionToken(r ServingRegion) string { + return model.SafeToken(r.Region) + "-" + model.SafeToken(r.Shard) +} + +func boundedName(s string) string { + if len(s) <= 63 { + return strings.Trim(s, "-") + } + sum := sha256.Sum256([]byte(s)) + return strings.Trim(s[:50], "-") + fmt.Sprintf("-%x", sum[:6]) +} diff --git a/internal/internaldns/controlplane/publication_reconciler.go b/internal/internaldns/controlplane/publication_reconciler.go new file mode 100644 index 0000000..692c276 --- /dev/null +++ b/internal/internaldns/controlplane/publication_reconciler.go @@ -0,0 +1,645 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controlplane + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "sort" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/internaldns/model" + "go.miloapis.com/dns-operator/internal/internaldns/projectstatus" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const privateZoneFinalizer = "internal-dns.dns.networking.miloapis.com/publication" + +func (r *Reconciler) reconcileProject(ctx context.Context, ns string) (ctrl.Result, error) { + now := r.Options.Now().UTC() + + if err := r.reconcileResolverContexts(ctx, ns, now); err != nil { + return ctrl.Result{}, err + } + + var zones dnsv1alpha1.DNSZoneList + var assocs dnsv1alpha1.DNSZoneAssociationList + var regs dnsv1alpha1.DNSRegistrationList + var contributions dnsv1alpha1.DNSRecordContributionList + var records dnsv1alpha1.DNSRecordSetList + var policies dnsv1alpha1.DNSNamingPolicyList + for _, item := range []client.ObjectList{&zones, &assocs, ®s, &contributions, &records, &policies} { + if err := r.List(ctx, item, client.InNamespace(ns)); err != nil { + return ctrl.Result{}, err + } + } + zoneByName := map[string]*dnsv1alpha1.DNSZone{} + for i := range zones.Items { + z := &zones.Items[i] + zoneByName[z.Name] = z + } + if err := r.reconcileAssociations(ctx, assocs.Items, zoneByName, now); err != nil { + return ctrl.Result{}, err + } + if err := r.reconcileNamingPolicies(ctx, policies.Items, assocs.Items, zoneByName, now); err != nil { + return ctrl.Result{}, err + } + if err := r.reconcileBindings(ctx, ns, assocs.Items, zoneByName, now); err != nil { + return ctrl.Result{}, err + } + var earliest time.Time + for i := range zones.Items { + z := &zones.Items[i] + if z.Spec.Visibility != dnsv1alpha1.DNSZoneVisibilityPrivate { + continue + } + if z.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(z, privateZoneFinalizer) { + base := z.DeepCopy() + controllerutil.AddFinalizer(z, privateZoneFinalizer) + if err := r.Patch(ctx, z, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + result, next, err := r.compileZone(ctx, z, assocs.Items, regs.Items, contributions.Items, records.Items, now) + if err != nil { + return ctrl.Result{}, err + } + if !next.IsZero() && (earliest.IsZero() || next.Before(earliest)) { + earliest = next + } + if err := r.aggregatePublicationStatus(ctx, z, regs.Items, contributions.Items, now); err != nil { + return ctrl.Result{}, err + } + if !z.DeletionTimestamp.IsZero() && result { + base := z.DeepCopy() + controllerutil.RemoveFinalizer(z, privateZoneFinalizer) + if err := r.Patch(ctx, z, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + } + } + return ctrl.Result{RequeueAfter: nextProjectRequeue(now, earliest, r.Options.LeaseDuration)}, nil +} + +// nextProjectRequeue never lets a later scheduled reconciliation +// postpone ownership renewal. A standby polls more frequently while another +// holder owns a zone, but the active holder must still run within half a lease +// even when the compiled publication is otherwise quiescent. +func nextProjectRequeue(now, earliest time.Time, lease time.Duration) time.Duration { + delay := lease / 2 + if delay <= 0 { + delay = time.Second + } + if !earliest.IsZero() { + until := earliest.Sub(now) + if until < delay { + delay = until + } + } + if delay < time.Second { + return time.Second + } + return delay +} + +func (r *Reconciler) aggregatePublicationStatus(ctx context.Context, z *dnsv1alpha1.DNSZone, regs []dnsv1alpha1.DNSRegistration, cs []dnsv1alpha1.DNSRecordContribution, now time.Time) error { + ownerName := "owner-" + model.OpaqueToken(string(z.UID))[:20] + var owner dnsv1alpha1.DNSPublicationOwnership + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: ownerName}, &owner); err != nil { + return client.IgnoreNotFound(err) + } + if owner.Spec.ActiveManifestName == "" { + return nil + } + var manifest dnsv1alpha1.DNSPublicationManifest + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: owner.Spec.ActiveManifestName}, &manifest); err != nil { + return client.IgnoreNotFound(err) + } + published := false + if c := apimeta.FindStatusCondition(manifest.Status.Conditions, "Published"); c != nil && c.Status == metav1.ConditionTrue { + published = true + } + regUIDs := map[types.UID]bool{} + registrationFences := map[types.UID]int64{} + for _, fence := range manifest.Spec.RegistrationFences { + registrationFences[fence.UID] = fence.Generation + } + for i := range regs { + reg := ®s[i] + if !refMatches(reg.Spec.DNSZoneRef, z.Name, z.UID, z.Generation) || registrationFences[reg.UID] != reg.Generation { + continue + } + regUIDs[reg.UID] = true + base := reg.DeepCopy() + reg.Status.PublicationWriterEpoch = manifest.Spec.WriterEpoch + reg.Status.PublicationRevision = manifest.Spec.Revision + projectstatus.Published(®.Status.Conditions, published, reg.Generation, now) + if !jsonEqual(base.Status, reg.Status) { + if err := r.Status().Patch(ctx, reg, client.MergeFrom(base)); err != nil { + return err + } + } + } + fenced := map[types.UID]int64{} + for _, f := range manifest.Spec.ContributionFences { + fenced[f.UID] = f.Generation + } + for i := range cs { + c := &cs[i] + if !regUIDs[c.Spec.RegistrationRef.UID] || fenced[c.UID] != c.Generation { + continue + } + var current dnsv1alpha1.DNSRecordContribution + if err := r.sourceReader().Get(ctx, client.ObjectKeyFromObject(c), ¤t); err != nil { + if apierrors.IsNotFound(err) { + continue + } + return err + } + if current.UID != c.UID || current.Generation != c.Generation { + continue + } + c = ¤t + base := c.DeepCopy() + status := metav1.ConditionFalse + reason, msg := "PendingReplicaVerification", "publication awaits required replica verification" + if published { + c.Status.PublishedRevision = manifest.Spec.Revision + status = metav1.ConditionTrue + reason = "Published" + msg = "contribution revision is verified by required serving replicas" + } + setCondition(&c.Status.Conditions, "Published", status, reason, msg, c.Generation, now) + if !jsonEqual(base.Status, c.Status) { + if err := r.Status().Patch(ctx, c, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return err + } + } + } + return nil +} + +func (r *Reconciler) compileZone(ctx context.Context, z *dnsv1alpha1.DNSZone, assocs []dnsv1alpha1.DNSZoneAssociation, regs []dnsv1alpha1.DNSRegistration, cs []dnsv1alpha1.DNSRecordContribution, records []dnsv1alpha1.DNSRecordSet, now time.Time) (bool, time.Time, error) { + own, err := r.acquireOwnership(ctx, z, now) + if err != nil { + return false, time.Time{}, err + } + if own == nil { + return false, now.Add(100 * time.Millisecond), nil + } + previous, err := r.loadPrevious(ctx, own) + if err != nil { + return false, time.Time{}, err + } + cs, err = r.confirmContributions(ctx, z.Namespace, cs, previous) + if err != nil { + return false, time.Time{}, err + } + input := CompileInput{Zone: z, Associations: assocs, Registrations: regs, Contributions: cs, StaticRecords: records, Previous: previous, Now: now} + tombstone := !z.DeletionTimestamp.IsZero() + var payload []byte + var ownPlan PublicationPlan + if !tombstone { + compiled, err := Compile(input) + if err != nil { + return false, time.Time{}, err + } + ownPlan = compiled.Plan + wire, err := WirePlan(compiled.Plan) + if err != nil { + return false, time.Time{}, err + } + payload, err = json.Marshal(wire) + if err != nil { + return false, time.Time{}, err + } + if err := r.patchInputStatuses(ctx, z, compiled, regs, cs, now); err != nil { + return false, time.Time{}, err + } + } + hash := model.Hash(payload) + servingTargets, err := r.publicationTargets(ctx, own, ownPlan.ContextUIDs, tombstone) + if err != nil { + return false, time.Time{}, err + } + stateHash := hash + if !tombstone { + fenceBytes, _ := json.Marshal(ownPlan.Contributions) + stateHash = model.Hash(append(append([]byte(nil), payload...), fenceBytes...)) + targetBytes, _ := json.Marshal(servingTargets) + stateHash = model.Hash(append([]byte(stateHash), targetBytes...)) + } + if tombstone { + hash = model.EmptyContentHash() + stateHash = hash + } + if own.Spec.LastContentHash == stateHash { + if !own.Spec.LeaseUntil.After(now.Add(r.Options.LeaseDuration / 2)) { + base := own.DeepCopy() + own.Spec.LeaseUntil = metav1.NewTime(now.Add(r.Options.LeaseDuration)) + if err := r.Patch(ctx, own, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return false, time.Time{}, err + } + } + return r.tombstoneAcknowledged(ctx, own), time.Time{}, nil + } + previousRevision, err := r.activeRevision(ctx, own) + if err != nil { + return false, time.Time{}, err + } + revision := own.Spec.NextRevision + if revision <= 0 { + revision = 1 + } + // Reserve the identity before staging immutable artifacts. A crash after this + // CAS leaves a harmless gap; a retry can never reuse (epoch, revision) with + // different generated bytes or timestamps. + reservationBase := own.DeepCopy() + own.Spec.NextRevision = revision + 1 + own.Spec.LeaseUntil = metav1.NewTime(now.Add(r.Options.LeaseDuration)) + if err := r.Patch(ctx, own, client.MergeFromWithOptions(reservationBase, client.MergeFromWithOptimisticLock{})); err != nil { + return false, time.Time{}, err + } + manifestName := artifactName("pub", z.UID, own.Spec.WriterEpoch, revision) + manifest, err := r.persistPublication(ctx, z, manifestName, own.Spec.WriterEpoch, revision, previousRevision, tombstone, payload, hash, ownPlan.ContextUIDs, ownPlan.Contributions, ownPlan.Registrations, servingTargets, now) + if err != nil { + return false, time.Time{}, err + } + base := own.DeepCopy() + own.Spec.LastContentHash = stateHash + own.Spec.ActiveManifestName = manifest.Name + own.Spec.LeaseUntil = metav1.NewTime(now.Add(r.Options.LeaseDuration)) + if err := r.Patch(ctx, own, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return false, time.Time{}, err + } + return false, time.Time{}, nil +} + +func (r *Reconciler) activeRevision(ctx context.Context, own *dnsv1alpha1.DNSPublicationOwnership) (int64, error) { + if own.Spec.ActiveManifestName == "" { + return 0, nil + } + var manifest dnsv1alpha1.DNSPublicationManifest + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: own.Spec.ActiveManifestName}, &manifest); err != nil { + return 0, err + } + if manifest.Spec.ZoneRef.UID != own.Spec.ZoneUID || manifest.Spec.WriterEpoch > own.Spec.WriterEpoch { + return 0, fmt.Errorf("active publication does not match ownership fence") + } + if manifest.Spec.WriterEpoch < own.Spec.WriterEpoch { + return 0, nil + } + return manifest.Spec.Revision, nil +} + +func (r *Reconciler) publicationTargets(ctx context.Context, own *dnsv1alpha1.DNSPublicationOwnership, contextsByUID []types.UID, includePrevious bool) ([]ServingRegion, error) { + var bindings dnsv1alpha1.DNSResolverBindingList + if err := r.List(ctx, &bindings, client.InNamespace(r.Options.PlatformNamespace)); err != nil { + return nil, err + } + byKey := map[string]ServingRegion{} + for _, resolverContextUID := range contextsByUID { + for _, target := range r.selectServingTargets(resolverContextUID, bindings.Items) { + byKey[target.Region+"\x00"+target.Shard] = target + } + } + if includePrevious && own.Spec.ActiveManifestName != "" { + var previous dnsv1alpha1.DNSPublicationManifest + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: own.Spec.ActiveManifestName}, &previous); err != nil { + return nil, err + } + for _, target := range previous.Spec.ServingTargets { + v := ServingRegion{Region: target.Region, Shard: target.Shard} + byKey[v.Region+"\x00"+v.Shard] = v + } + } + result := make([]ServingRegion, 0, len(byKey)) + for _, target := range byKey { + result = append(result, target) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Region == result[j].Region { + return result[i].Shard < result[j].Shard + } + return result[i].Region < result[j].Region + }) + return result, nil +} + +func (r *Reconciler) persistPublication(ctx context.Context, z *dnsv1alpha1.DNSZone, name string, epoch, revision, previous int64, tombstone bool, payload []byte, hash string, contextUIDs []types.UID, fences []ContributionFence, registrations []RegistrationFence, servingTargets []ServingRegion, now time.Time) (*dnsv1alpha1.DNSPublicationManifest, error) { + contextUIDs = append([]types.UID{}, contextUIDs...) + refs := []dnsv1alpha1.DNSPublicationChunkReference{} + depsByRegion := map[string][]string{} + wireRefs := []model.ChunkRef{} + parts := chunk(payload, r.Options.ChunkSize) + for i, p := range parts { + chunkName := fmt.Sprintf("%s-%03d", name, i) + sum := model.Hash(p) + obj := &dnsv1alpha1.DNSPublicationChunk{ObjectMeta: metav1.ObjectMeta{Name: chunkName, Namespace: r.Options.PlatformNamespace}, Spec: dnsv1alpha1.DNSPublicationChunkSpec{WriterEpoch: epoch, Revision: revision, Index: int32(i), SHA256: sum, Payload: p}} + if err := r.createImmutable(ctx, obj); err != nil { + return nil, err + } + refs = append(refs, dnsv1alpha1.DNSPublicationChunkReference{Name: chunkName, SHA256: sum, Size: int32(len(p))}) + wireRefs = append(wireRefs, model.ChunkRef{Index: i, SHA256: sum, Size: len(p)}) + wire := model.PublicationChunk{ManifestUID: name, ZoneUID: string(z.UID), WriterEpoch: uint64(epoch), Revision: uint64(revision), Index: i, SHA256: sum, Payload: p} + for _, target := range servingTargets { + token := regionToken(target) + eventID := token + ":" + chunkName + env, err := model.NewEnvelope(model.KindPublicationChunk, eventID, target.Region, target.Shard, string(z.UID), uint64(epoch), uint64(revision), now, wire) + if err != nil { + return nil, err + } + b, _ := json.Marshal(env) + outName := boundedName(chunkName + "-" + token + "-export") + out := r.outbox(outName, target.Region, target.Shard, model.RecordChunkSubject(target.Region, target.Shard, string(z.UID)), z.UID, epoch, revision, name, b, false, nil) + if err := r.createImmutable(ctx, out); err != nil { + return nil, err + } + depsByRegion[token] = append(depsByRegion[token], out.Name) + } + } + apiFences := make([]dnsv1alpha1.DNSContributionFence, 0, len(fences)) + for _, f := range fences { + apiFences = append(apiFences, dnsv1alpha1.DNSContributionFence{UID: f.UID, Generation: f.Generation, Sequence: f.Sequence, Retired: f.Retired, SourceName: f.SourceName}) + } + apiRegistrations := make([]dnsv1alpha1.DNSRegistrationFence, 0, len(registrations)) + for _, f := range registrations { + apiRegistrations = append(apiRegistrations, dnsv1alpha1.DNSRegistrationFence{UID: f.UID, Generation: f.Generation}) + } + apiTargets := make([]dnsv1alpha1.DNSPublicationTarget, 0, len(servingTargets)) + for _, target := range servingTargets { + apiTargets = append(apiTargets, dnsv1alpha1.DNSPublicationTarget{Region: target.Region, Shard: target.Shard}) + } + m := &dnsv1alpha1.DNSPublicationManifest{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: r.Options.PlatformNamespace, Labels: map[string]string{"internal-dns.miloapis.com/zone-uid": string(z.UID)}}, Spec: dnsv1alpha1.DNSPublicationManifestSpec{ZoneRef: dnsv1alpha1.DNSObjectReference{Name: z.Name, UID: z.UID}, ZoneApex: z.Spec.DomainName, ContextUIDs: contextUIDs, WriterEpoch: epoch, Revision: revision, PreviousRevision: previous, Tombstone: tombstone, Chunks: refs, ContentHash: hash, GeneratedAt: metav1.NewTime(now), ContributionFences: apiFences, RegistrationFences: apiRegistrations, ServingTargets: apiTargets}} + if err := r.createImmutable(ctx, m); err != nil { + return nil, err + } + wmanifest := model.PublicationManifest{ManifestUID: name, ZoneUID: string(z.UID), Apex: z.Spec.DomainName, WriterEpoch: uint64(epoch), Revision: uint64(revision), PreviousRevision: uint64(previous), Tombstone: tombstone, Chunks: wireRefs, ContentHash: hash, GeneratedAt: now} + if err := wmanifest.Validate(); err != nil { + return nil, err + } + for _, target := range servingTargets { + token := regionToken(target) + eventID := token + ":" + name + env, err := model.NewEnvelope(model.KindPublicationManifest, eventID, target.Region, target.Shard, string(z.UID), uint64(epoch), uint64(revision), now, wmanifest) + if err != nil { + return nil, err + } + b, _ := json.Marshal(env) + out := r.outbox(model.PublicationActivationName(name, target.Region, target.Shard), target.Region, target.Shard, model.RecordManifestSubject(target.Region, target.Shard, string(z.UID)), z.UID, epoch, revision, name, b, true, depsByRegion[token]) + if err := r.createImmutable(ctx, out); err != nil { + return nil, err + } + } + return m, nil +} + +func (r *Reconciler) outbox(name, region, shard, subject string, uid types.UID, epoch, revision int64, manifest string, payload []byte, activation bool, deps []string) *dnsv1alpha1.DNSTransportOutbox { + ownerName := "" + if activation { + ownerName = "owner-" + model.OpaqueToken(string(uid))[:20] + } + return &dnsv1alpha1.DNSTransportOutbox{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: r.Options.PlatformNamespace, Labels: map[string]string{ + "internal-dns.miloapis.com/region": model.SafeToken(region), + "internal-dns.miloapis.com/shard": model.SafeToken(shard), + }}, Spec: dnsv1alpha1.DNSTransportOutboxSpec{Subject: subject, ResourceUID: uid, WriterEpoch: epoch, Revision: revision, ManifestRef: dnsv1alpha1.DNSObjectReference{Name: manifest}, OwnershipRef: dnsv1alpha1.DNSObjectReference{Name: ownerName}, PayloadHash: model.Hash(payload), Payload: payload, Activation: activation, DependsOn: deps}} +} + +func (r *Reconciler) acquireOwnership(ctx context.Context, z *dnsv1alpha1.DNSZone, now time.Time) (*dnsv1alpha1.DNSPublicationOwnership, error) { + name := "owner-" + model.OpaqueToken(string(z.UID))[:20] + o := &dnsv1alpha1.DNSPublicationOwnership{} + key := client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: name} + err := r.Get(ctx, key, o) + if apierrors.IsNotFound(err) { + o = &dnsv1alpha1.DNSPublicationOwnership{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: key.Namespace}, Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ZoneUID: z.UID, HolderIdentity: r.Options.Identity, WriterEpoch: 1, NextRevision: 1, LeaseUntil: metav1.NewTime(now.Add(r.Options.LeaseDuration))}} + if err := r.Create(ctx, o); err != nil && !apierrors.IsAlreadyExists(err) { + return nil, err + } + return nil, nil + } + if err != nil { + return nil, err + } + if o.Spec.ZoneUID != z.UID { + return nil, fmt.Errorf("ownership collision for %s", z.UID) + } + if o.Spec.HolderIdentity != r.Options.Identity { + if o.Spec.LeaseUntil.After(now) { + return nil, nil + } + base := o.DeepCopy() + o.Spec.HolderIdentity = r.Options.Identity + o.Spec.WriterEpoch++ + o.Spec.LeaseUntil = metav1.NewTime(now.Add(r.Options.LeaseDuration)) + if err := r.Patch(ctx, o, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return nil, err + } + } + return o, nil +} + +func (r *Reconciler) loadPrevious(ctx context.Context, o *dnsv1alpha1.DNSPublicationOwnership) (*PublicationPlan, error) { + if o.Spec.ActiveManifestName == "" { + return nil, nil + } + var m dnsv1alpha1.DNSPublicationManifest + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: o.Spec.ActiveManifestName}, &m); err != nil { + return nil, fmt.Errorf("load active publication manifest %s: %w", o.Spec.ActiveManifestName, err) + } + var b []byte + for i, ref := range m.Spec.Chunks { + var c dnsv1alpha1.DNSPublicationChunk + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: ref.Name}, &c); err != nil { + return nil, fmt.Errorf("load active publication chunk %s: %w", ref.Name, err) + } + if int(c.Spec.Index) != i || c.Spec.WriterEpoch != m.Spec.WriterEpoch || c.Spec.Revision != m.Spec.Revision || c.Spec.SHA256 != ref.SHA256 || model.Hash(c.Spec.Payload) != ref.SHA256 || len(c.Spec.Payload) != int(ref.Size) { + return nil, fmt.Errorf("publication %s chunk %d failed verification", m.Name, i) + } + b = append(b, c.Spec.Payload...) + } + if model.Hash(b) != m.Spec.ContentHash { + return nil, fmt.Errorf("publication %s content hash mismatch", m.Name) + } + if len(b) == 0 { + if !m.Spec.Tombstone { + return nil, fmt.Errorf("publication %s has an empty non-tombstone payload", m.Name) + } + return nil, nil + } + var wire model.PublicationPlan + if err := json.Unmarshal(b, &wire); err != nil { + return nil, fmt.Errorf("decode active publication %s: %w", m.Name, err) + } + if err := wire.Validate(); err != nil { + return nil, fmt.Errorf("validate active publication %s: %w", m.Name, err) + } + p := &PublicationPlan{ZoneUID: types.UID(wire.ZoneUID), ZoneApex: wire.Apex} + // The chunk payload is the canonical immutable fence. Kubernetes date-time + // fields may lose fractional seconds during API round trips, so rebuilding + // from manifest metadata can make an unchanged observation appear mutated. + for _, f := range wire.ObservationFences { + p.Contributions = append(p.Contributions, ContributionFence{UID: types.UID(f.ContributionUID), Generation: int64(f.Generation), Sequence: int64(f.Sequence), Retired: f.Retired, SourceName: f.SourceName}) + } + return p, nil +} + +func (r *Reconciler) patchInputStatuses(ctx context.Context, zone *dnsv1alpha1.DNSZone, result CompileResult, regs []dnsv1alpha1.DNSRegistration, cs []dnsv1alpha1.DNSRecordContribution, now time.Time) error { + relevant := map[types.UID]bool{} + for i := range regs { + reg := ®s[i] + if !refMatches(reg.Spec.DNSZoneRef, zone.Name, zone.UID, zone.Generation) { + continue + } + relevant[reg.UID] = true + accepted := result.Accepted[reg.UID] + reason := result.Reasons[reg.UID] + if reason == "" { + reason = "Accepted" + } + base := reg.DeepCopy() + reg.Status.CanonicalFQDN = absoluteOwner(reg.Spec.Name, result.Plan.ZoneApex) + if !accepted { + reg.Status.CanonicalFQDN = "" + reg.Status.PublicationWriterEpoch = 0 + reg.Status.PublicationRevision = 0 + projectstatus.Published(®.Status.Conditions, false, reg.Generation, now) + } + reg.Status.ObservedGeneration = reg.Generation + setCondition(®.Status.Conditions, "Accepted", boolCondition(accepted), reason, reason, reg.Generation, now) + if err := r.Status().Patch(ctx, reg, client.MergeFrom(base)); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + for i := range cs { + c := &cs[i] + if !relevant[c.Spec.RegistrationRef.UID] { + continue + } + accepted := result.Accepted[c.UID] + reason := result.Reasons[c.UID] + if reason == "" { + reason = "Accepted" + } + base := c.DeepCopy() + c.Status.ObservedGeneration = c.Generation + setCondition(&c.Status.Conditions, "Accepted", boolCondition(accepted), reason, reason, c.Generation, now) + if err := r.Status().Patch(ctx, c, client.MergeFrom(base)); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} + +func (r *Reconciler) tombstoneAcknowledged(ctx context.Context, o *dnsv1alpha1.DNSPublicationOwnership) bool { + if o.Spec.ActiveManifestName == "" { + return false + } + var manifest dnsv1alpha1.DNSPublicationManifest + if err := r.Get(ctx, client.ObjectKey{Namespace: r.Options.PlatformNamespace, Name: o.Spec.ActiveManifestName}, &manifest); err != nil { + return false + } + c := apimeta.FindStatusCondition(manifest.Status.Conditions, "Published") + if !manifest.Spec.Tombstone || manifest.Spec.ZoneRef.UID != o.Spec.ZoneUID || manifest.Spec.WriterEpoch > o.Spec.WriterEpoch { + return false + } + // Zero targets are safe only after this manifest became the ownership commit + // pointer. publicationTargets retains prior targets for every withdrawal. + return len(manifest.Spec.ServingTargets) == 0 || (c != nil && c.Status == metav1.ConditionTrue) +} + +func (r *Reconciler) createImmutable(ctx context.Context, desired client.Object) error { + if err := r.Create(ctx, desired); err == nil { + return nil + } else if !apierrors.IsAlreadyExists(err) { + return err + } + switch want := desired.(type) { + case *dnsv1alpha1.DNSPublicationChunk: + var have dnsv1alpha1.DNSPublicationChunk + if err := r.Get(ctx, client.ObjectKeyFromObject(want), &have); err != nil { + return err + } + if !reflect.DeepEqual(have.Spec, want.Spec) { + return fmt.Errorf("immutable publication chunk %s has conflicting content", want.Name) + } + case *dnsv1alpha1.DNSPublicationManifest: + var have dnsv1alpha1.DNSPublicationManifest + if err := r.Get(ctx, client.ObjectKeyFromObject(want), &have); err != nil { + return err + } + if !reflect.DeepEqual(have.Spec, want.Spec) { + return fmt.Errorf("immutable publication manifest %s has conflicting content", want.Name) + } + case *dnsv1alpha1.DNSTransportOutbox: + var have dnsv1alpha1.DNSTransportOutbox + if err := r.Get(ctx, client.ObjectKeyFromObject(want), &have); err != nil { + return err + } + if !reflect.DeepEqual(have.Spec, want.Spec) { + return fmt.Errorf("immutable transport outbox %s has conflicting content", want.Name) + } + default: + return fmt.Errorf("immutable create is unsupported for %T", desired) + } + return nil +} + +func artifactName(prefix string, uid types.UID, epoch, rev int64) string { + return fmt.Sprintf("%s-%s-e%d-r%d", prefix, model.OpaqueToken(string(uid))[:16], epoch, rev) +} + +func chunk(b []byte, size int) [][]byte { + if len(b) == 0 { + return nil + } + var out [][]byte + for len(b) > 0 { + n := size + if n > len(b) { + n = len(b) + } + out = append(out, append([]byte(nil), b[:n]...)) + b = b[n:] + } + return out +} + +func (r *Reconciler) confirmContributions(ctx context.Context, namespace string, listed []dnsv1alpha1.DNSRecordContribution, previous *PublicationPlan) ([]dnsv1alpha1.DNSRecordContribution, error) { + reader := r.sourceReader() + names := map[string]bool{} + for _, c := range listed { + names[c.Name] = true + } + if previous != nil { + for _, fence := range previous.Contributions { + if !fence.Retired { + if fence.SourceName == "" { + return nil, fmt.Errorf("contribution %s has no source identity for lifetime confirmation", fence.UID) + } + names[fence.SourceName] = true + } + } + } + confirmed := make([]dnsv1alpha1.DNSRecordContribution, 0, len(names)) + for name := range names { + var current dnsv1alpha1.DNSRecordContribution + if err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, ¤t); err != nil { + if apierrors.IsNotFound(err) { + continue + } + return nil, err + } + confirmed = append(confirmed, current) + } + return confirmed, nil +} diff --git a/internal/internaldns/controlplane/publication_reconciler_test.go b/internal/internaldns/controlplane/publication_reconciler_test.go new file mode 100644 index 0000000..a66a094 --- /dev/null +++ b/internal/internaldns/controlplane/publication_reconciler_test.go @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controlplane + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/internaldns/model" + 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/client/fake" +) + +func TestExpiredOwnershipTakeoverFencesResumedWriter(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "zone", Namespace: "project", UID: "zone-a"}} + ownerName := "owner-" + model.OpaqueToken(string(zone.UID))[:20] + owner := &dnsv1alpha1.DNSPublicationOwnership{ObjectMeta: metav1.ObjectMeta{Name: ownerName, Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ + ZoneUID: zone.UID, HolderIdentity: "writer-a", WriterEpoch: 4, NextRevision: 12, + ActiveManifestName: "active-e4-r11", LeaseUntil: metav1.NewTime(now.Add(-time.Second)), + }} + c := fake.NewClientBuilder().WithScheme(s).WithObjects(owner).Build() + writerB := &Reconciler{Client: c, Options: ReconcilerOptions{PlatformNamespace: "platform", Identity: "writer-b", LeaseDuration: 30 * time.Second}} + acquired, err := writerB.acquireOwnership(ctx, zone, now) + if err != nil || acquired == nil { + t.Fatalf("takeover failed: owner=%#v err=%v", acquired, err) + } + if acquired.Spec.HolderIdentity != "writer-b" || acquired.Spec.WriterEpoch != 5 || acquired.Spec.NextRevision != 12 || acquired.Spec.ActiveManifestName != "active-e4-r11" { + t.Fatalf("takeover corrupted the durable fence: %#v", acquired.Spec) + } + writerA := &Reconciler{Client: c, Options: ReconcilerOptions{PlatformNamespace: "platform", Identity: "writer-a", LeaseDuration: 30 * time.Second}} + resumed, err := writerA.acquireOwnership(ctx, zone, now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if resumed != nil { + t.Fatalf("resumed old writer reacquired a live lease: %#v", resumed.Spec) + } + var current dnsv1alpha1.DNSPublicationOwnership + if err := c.Get(ctx, client.ObjectKey{Namespace: "platform", Name: ownerName}, ¤t); err != nil { + t.Fatal(err) + } + if current.Spec.HolderIdentity != "writer-b" || current.Spec.WriterEpoch != 5 || current.Spec.ActiveManifestName != "active-e4-r11" { + t.Fatalf("resumed writer regressed ownership: %#v", current.Spec) + } +} + +func TestScheduledReconciliationCannotDelayOwnershipRenewal(t *testing.T) { + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + lease := 10 * time.Second + if got := nextProjectRequeue(now, now.Add(90*time.Second), lease); got != 5*time.Second { + t.Fatalf("later reconciliation delayed lease renewal: got %s want 5s", got) + } + if got := nextProjectRequeue(now, now.Add(2*time.Second), lease); got != 2*time.Second { + t.Fatalf("earlier reconciliation was not honored: got %s want 2s", got) + } +} + +func TestQuiescentSurvivorRenewsOwnershipBeyondLeaseWindow(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + network := &dnsv1alpha1.DNSResolverContext{ObjectMeta: metav1.ObjectMeta{Name: "prod", Namespace: "project", UID: "vpc-a"}, Spec: dnsv1alpha1.DNSResolverContextSpec{ConsumerID: "consumer-a"}} + input := fixture(now) + zone := input.Zone.DeepCopy() + zone.Namespace = "project" + zone.Finalizers = []string{privateZoneFinalizer} + assoc := input.Associations[0].DeepCopy() + assoc.Namespace = "project" + assoc.Name = "zone-prod" + assoc.Spec.ResolverContextRef = dnsv1alpha1.DNSObjectReference{Name: "prod", UID: "vpc-a"} + assoc.Status = dnsv1alpha1.DNSZoneAssociationStatus{} + reg := input.Registrations[0].DeepCopy() + reg.Namespace = "project" + contribution := input.Contributions[0].DeepCopy() + contribution.Namespace = "project" + contribution.Generation = 1 + c := fake.NewClientBuilder().WithScheme(s).WithObjects(network, zone, assoc, reg, contribution). + WithStatusSubresource(&dnsv1alpha1.DNSResolverContext{}, &dnsv1alpha1.DNSResolverAccessBinding{}, &dnsv1alpha1.DNSZoneAssociation{}, &dnsv1alpha1.DNSRegistration{}, &dnsv1alpha1.DNSRecordContribution{}, &dnsv1alpha1.DNSPublicationManifest{}).Build() + options := ReconcilerOptions{ + ProjectUID: "project-a", ProjectNamespace: "project", PlatformNamespace: "platform", + Identity: "writer-a", LeaseDuration: 10 * time.Second, + ServingRegions: []ServingRegion{{Region: "east", Shard: "shared"}}, AddressAllocator: testAllocator{}, Now: func() time.Time { return now }, + } + writerA := &Reconciler{Client: c, Scheme: s, Options: options} + request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "project", Name: "project"}} + for i := 0; i < 8; i++ { + if _, err := writerA.Reconcile(ctx, request); err != nil { + t.Fatalf("initial reconcile %d: %v", i, err) + } + } + + // Stop A for longer than its lease, then let B take over the otherwise + // quiescent 90-second publication. + now = now.Add(11 * time.Second) + options.Identity = "writer-b" + writerB := &Reconciler{Client: c, Scheme: s, Options: options} + result, err := writerB.Reconcile(ctx, request) + if err != nil { + t.Fatal(err) + } + if result.RequeueAfter > 5*time.Second { + t.Fatalf("survivor scheduled after its lease-renewal boundary: %s", result.RequeueAfter) + } + + // Run B at each requested renewal and resume A after it. More than a full + // lease elapses, but A must never reacquire while B remains healthy. + for i := 0; i < 3; i++ { + now = now.Add(result.RequeueAfter) + result, err = writerB.Reconcile(ctx, request) + if err != nil { + t.Fatalf("survivor renewal %d: %v", i, err) + } + if result.RequeueAfter > 5*time.Second { + t.Fatalf("survivor renewal %d scheduled too late: %s", i, result.RequeueAfter) + } + if _, err := writerA.Reconcile(ctx, request); err != nil { + t.Fatalf("resumed writer reconcile %d: %v", i, err) + } + } + var owner dnsv1alpha1.DNSPublicationOwnership + ownerName := "owner-" + model.OpaqueToken(string(zone.UID))[:20] + if err := c.Get(ctx, client.ObjectKey{Namespace: "platform", Name: ownerName}, &owner); err != nil { + t.Fatal(err) + } + if owner.Spec.HolderIdentity != "writer-b" || owner.Spec.WriterEpoch != 2 { + t.Fatalf("resumed old writer displaced healthy survivor: %#v", owner.Spec) + } +} + +func TestOwnershipTakeoverLoadsOldEpochHighWater(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(model.PublicationPlan{ZoneUID: "zone-a", Apex: "corp.internal", ObservationFences: []model.ObservationFence{{ContributionUID: "contribution-a", Sequence: 19, Generation: 19}}, RRSets: []model.RRSet{}}) + if err != nil { + t.Fatal(err) + } + chunk := &dnsv1alpha1.DNSPublicationChunk{ + ObjectMeta: metav1.ObjectMeta{Name: "old-000", Namespace: "internal-dns-system"}, + Spec: dnsv1alpha1.DNSPublicationChunkSpec{WriterEpoch: 1, Revision: 9, Index: 0, SHA256: model.Hash(payload), Payload: payload}, + } + manifest := &dnsv1alpha1.DNSPublicationManifest{ + ObjectMeta: metav1.ObjectMeta{Name: "old", Namespace: "internal-dns-system"}, + Spec: dnsv1alpha1.DNSPublicationManifestSpec{ + ZoneRef: dnsv1alpha1.DNSObjectReference{UID: "zone-a"}, WriterEpoch: 1, Revision: 9, + Chunks: []dnsv1alpha1.DNSPublicationChunkReference{{Name: chunk.Name, SHA256: chunk.Spec.SHA256, Size: int32(len(payload))}}, + ContentHash: model.Hash(payload), + // The metadata deliberately represents the precision an API server may + // retain. Recovery must use the exact immutable wire observation. + ContributionFences: []dnsv1alpha1.DNSContributionFence{{UID: "contribution-a", Sequence: 19, Generation: 7}}, + }, + } + owner := &dnsv1alpha1.DNSPublicationOwnership{ + ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: "internal-dns-system"}, + Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ZoneUID: "zone-a", WriterEpoch: 2, ActiveManifestName: manifest.Name}, + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(chunk, manifest, owner).Build() + r := &Reconciler{Client: c, Options: ReconcilerOptions{PlatformNamespace: "internal-dns-system"}} + revision, err := r.activeRevision(ctx, owner) + if err != nil { + t.Fatal(err) + } + if revision != 0 { + t.Fatalf("new writer epoch inherited old revision %d", revision) + } + previous, err := r.loadPrevious(ctx, owner) + if err != nil { + t.Fatal(err) + } + if len(previous.Contributions) != 1 || previous.Contributions[0].Sequence != 19 { + t.Fatalf("old epoch high-water fence was lost: %#v", previous) + } + in := fixture(now) + in.Previous = previous + in.Contributions[0].Generation = 18 + compiled, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if compiled.Reasons["contribution-a"] != "StaleContributionGeneration" || len(compiled.Plan.RRsets) != 0 { + t.Fatalf("ownership takeover replayed a stale contribution: %#v", compiled) + } +} + +func TestLoadPreviousFailsWhenActiveChunkIsMissing(t *testing.T) { + ctx := context.Background() + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + manifest := &dnsv1alpha1.DNSPublicationManifest{ + ObjectMeta: metav1.ObjectMeta{Name: "active", Namespace: "internal-dns-system"}, + Spec: dnsv1alpha1.DNSPublicationManifestSpec{ + WriterEpoch: 1, Revision: 1, ContentHash: model.Hash([]byte("missing")), + Chunks: []dnsv1alpha1.DNSPublicationChunkReference{{Name: "missing", SHA256: model.Hash([]byte("missing")), Size: 7}}, + }, + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(manifest).Build() + r := &Reconciler{Client: c, Options: ReconcilerOptions{PlatformNamespace: "internal-dns-system"}} + owner := &dnsv1alpha1.DNSPublicationOwnership{Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ActiveManifestName: manifest.Name}} + if _, err := r.loadPrevious(ctx, owner); err == nil { + t.Fatal("missing active chunk was silently ignored") + } +} + +func TestLoadPreviousRejectsHashValidMalformedPayload(t *testing.T) { + ctx := context.Background() + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + payload := []byte(`{"zoneUID":`) + chunk := &dnsv1alpha1.DNSPublicationChunk{ObjectMeta: metav1.ObjectMeta{Name: "chunk", Namespace: "internal-dns-system"}, Spec: dnsv1alpha1.DNSPublicationChunkSpec{WriterEpoch: 1, Revision: 1, Index: 0, SHA256: model.Hash(payload), Payload: payload}} + manifest := &dnsv1alpha1.DNSPublicationManifest{ObjectMeta: metav1.ObjectMeta{Name: "active", Namespace: "internal-dns-system"}, Spec: dnsv1alpha1.DNSPublicationManifestSpec{ + WriterEpoch: 1, Revision: 1, ContentHash: model.Hash(payload), Chunks: []dnsv1alpha1.DNSPublicationChunkReference{{Name: chunk.Name, SHA256: chunk.Spec.SHA256, Size: int32(len(payload))}}, + }} + c := fake.NewClientBuilder().WithScheme(s).WithObjects(manifest, chunk).Build() + r := &Reconciler{Client: c, Options: ReconcilerOptions{PlatformNamespace: "internal-dns-system"}} + owner := &dnsv1alpha1.DNSPublicationOwnership{Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ActiveManifestName: manifest.Name}} + if _, err := r.loadPrevious(ctx, owner); err == nil || !strings.Contains(err.Error(), "decode active publication") { + t.Fatalf("hash-valid malformed payload was silently accepted: %v", err) + } +} + +func TestEmptyPublicationUsesJSONArraysForRequiredSlices(t *testing.T) { + ctx := context.Background() + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + c := fake.NewClientBuilder().WithScheme(s).Build() + r := &Reconciler{Client: c, Options: ReconcilerOptions{PlatformNamespace: "internal-dns-system", ChunkSize: 1024}} + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "orphan", UID: "zone-orphan"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "orphan.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + manifest, err := r.persistPublication(ctx, zone, "empty", 1, 1, 0, true, nil, model.EmptyContentHash(), nil, nil, nil, nil, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if manifest.Spec.ContextUIDs == nil || manifest.Spec.Chunks == nil { + t.Fatalf("required manifest slices must be non-nil: %#v", manifest.Spec) + } + b, err := json.Marshal(manifest.Spec) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"contextUIDs":[]`) || !strings.Contains(string(b), `"chunks":[]`) { + t.Fatalf("required slices encoded as null: %s", b) + } +} + +func TestReconcilerPersistsFencedMultiRegionOutbox(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + s := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(s); err != nil { + t.Fatal(err) + } + network := &dnsv1alpha1.DNSResolverContext{ObjectMeta: metav1.ObjectMeta{Name: "prod", Namespace: "project", UID: "vpc-a"}, Spec: dnsv1alpha1.DNSResolverContextSpec{ConsumerID: "consumer-a"}} + zone := fixture(now).Zone.DeepCopy() + zone.Namespace = "project" + zone.Finalizers = []string{privateZoneFinalizer} + assoc := fixture(now).Associations[0].DeepCopy() + assoc.Namespace = "project" + assoc.Name = "zone-prod" + assoc.Spec.ResolverContextRef = dnsv1alpha1.DNSObjectReference{Name: "prod", UID: "vpc-a"} + assoc.Status = dnsv1alpha1.DNSZoneAssociationStatus{} + reg := fixture(now).Registrations[0].DeepCopy() + reg.Namespace = "project" + contrib := fixture(now).Contributions[0].DeepCopy() + contrib.Namespace = "project" + contrib.Generation = 1 + accessEast := &dnsv1alpha1.DNSResolverAccessBinding{ObjectMeta: metav1.ObjectMeta{Name: "prod-east", Namespace: "project", UID: "access-east"}, Spec: dnsv1alpha1.DNSResolverAccessBindingSpec{ContextRef: dnsv1alpha1.DNSObjectReference{Name: "prod", UID: "vpc-a"}, Region: "east", DestinationAddress: "fd53::11"}} + accessWest := accessEast.DeepCopy() + accessWest.Name = "prod-west" + accessWest.UID = "access-west" + accessWest.Spec.Region = "west" + accessWest.Spec.DestinationAddress = "fd53::12" + c := fake.NewClientBuilder().WithScheme(s).WithObjects(network, zone, assoc, reg, contrib, accessEast, accessWest).WithStatusSubresource(&dnsv1alpha1.DNSResolverContext{}, &dnsv1alpha1.DNSResolverAccessBinding{}, &dnsv1alpha1.DNSZoneAssociation{}, &dnsv1alpha1.DNSRegistration{}, &dnsv1alpha1.DNSRecordContribution{}, &dnsv1alpha1.DNSPublicationManifest{}).Build() + r := &Reconciler{Client: c, Scheme: s, Options: ReconcilerOptions{ProjectUID: "project-a", ProjectNamespace: "project", PlatformNamespace: "internal-dns-system", Identity: "compiler-a", ServingRegions: []ServingRegion{{Region: "east", Shard: "s1"}, {Region: "west", Shard: "s2"}}, AddressAllocator: testAllocator{}, Now: func() time.Time { return now }}} + for i := 0; i < 8; i++ { + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "project", Name: "project"}}); err != nil { + t.Fatalf("reconcile %d: %v", i, err) + } + } + var bindings dnsv1alpha1.DNSResolverBindingList + if err := c.List(ctx, &bindings); err != nil { + t.Fatal(err) + } + if len(bindings.Items) != 2 { + t.Fatalf("want two regional bindings, got %d", len(bindings.Items)) + } + var manifests dnsv1alpha1.DNSPublicationManifestList + if err := c.List(ctx, &manifests); err != nil { + t.Fatal(err) + } + if len(manifests.Items) != 1 { + t.Fatalf("want one shared manifest, got %d: %#v", len(manifests.Items), manifests.Items) + } + m := manifests.Items[0] + var chunks dnsv1alpha1.DNSPublicationChunkList + if err := c.List(ctx, &chunks); err != nil { + t.Fatal(err) + } + if len(chunks.Items) != 1 { + t.Fatalf("want one chunk, got %d", len(chunks.Items)) + } + var plan model.PublicationPlan + if err := json.Unmarshal(chunks.Items[0].Spec.Payload, &plan); err != nil { + t.Fatal(err) + } + if err := plan.Validate(); err != nil { + t.Fatal(err) + } + if len(plan.RRSets) != 1 { + t.Fatalf("record was not published: %#v", plan) + } + var outboxes dnsv1alpha1.DNSTransportOutboxList + if err := c.List(ctx, &outboxes); err != nil { + t.Fatal(err) + } + if len(outboxes.Items) != 4 { + t.Fatalf("want chunk+activation per region, got %d", len(outboxes.Items)) + } + events := map[string]bool{} + for _, o := range outboxes.Items { + var e model.Envelope + if err := json.Unmarshal(o.Spec.Payload, &e); err != nil { + t.Fatal(err) + } + if err := e.Validate(); err != nil { + t.Fatal(err) + } + if events[e.EventID] { + t.Fatalf("duplicate multi-region event ID %q", e.EventID) + } + events[e.EventID] = true + if o.Spec.Activation && (len(o.Spec.DependsOn) != 1 || o.Spec.OwnershipRef.Name == "") { + t.Fatalf("activation was not fenced: %#v", o.Spec) + } + } + if len(m.Spec.ContributionFences) != 1 || m.Spec.ContributionFences[0].Sequence != 1 { + t.Fatalf("missing durable contribution high-water: %#v", m.Spec.ContributionFences) + } +} diff --git a/internal/internaldns/controlplane/publication_regression_test.go b/internal/internaldns/controlplane/publication_regression_test.go new file mode 100644 index 0000000..ca094fd --- /dev/null +++ b/internal/internaldns/controlplane/publication_regression_test.go @@ -0,0 +1,84 @@ +package controlplane + +import ( + "context" + "testing" + "time" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/internaldns/model" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestTerminatingInputsWithdrawRecordsAndRetainFences(t *testing.T) { + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + mark func(*CompileInput) + }{ + {"registration", func(in *CompileInput) { + in.Registrations[0].Finalizers = []string{"example.test/hold"} + in.Registrations[0].DeletionTimestamp = &metav1.Time{Time: now.Add(-time.Second)} + }}, + {"contribution", func(in *CompileInput) { + in.Contributions[0].Finalizers = []string{"example.test/hold"} + in.Contributions[0].DeletionTimestamp = &metav1.Time{Time: now.Add(-time.Second)} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + in := fixture(now) + before, err := Compile(in) + if err != nil { + t.Fatal(err) + } + in.Previous = &before.Plan + tc.mark(&in) + got, err := Compile(in) + if err != nil { + t.Fatal(err) + } + if len(got.Plan.RRsets) != 0 || got.Accepted[in.Contributions[0].UID] { + t.Fatalf("control retained terminating %s: %#v", tc.name, got) + } + if len(got.Plan.Contributions) != 1 || got.Plan.Contributions[0].Sequence != 18 { + t.Fatalf("lost withdrawal high-water fence: %#v", got.Plan.Contributions) + } + }) + } +} + +func TestCommittedUntargetedTombstoneCompletes(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&dnsv1alpha1.DNSPublicationManifest{}).Build() + r := &Reconciler{Client: cl, Options: ReconcilerOptions{PlatformNamespace: "platform", ChunkSize: 1024}} + zone := &dnsv1alpha1.DNSZone{ObjectMeta: metav1.ObjectMeta{Name: "orphan", UID: "zone-orphan"}, Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "orphan.internal", Visibility: dnsv1alpha1.DNSZoneVisibilityPrivate}} + manifest, err := r.persistPublication(ctx, zone, "tombstone", 1, 1, 0, true, nil, model.EmptyContentHash(), nil, nil, nil, nil, now) + if err != nil { + t.Fatal(err) + } + owner := &dnsv1alpha1.DNSPublicationOwnership{ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: "platform"}, Spec: dnsv1alpha1.DNSPublicationOwnershipSpec{ZoneUID: zone.UID, WriterEpoch: 1, ActiveManifestName: manifest.Name}} + if err := cl.Create(ctx, owner); err != nil { + t.Fatal(err) + } + var outboxes dnsv1alpha1.DNSTransportOutboxList + if err := cl.List(ctx, &outboxes); err != nil { + t.Fatal(err) + } + if len(outboxes.Items) != 0 { + t.Fatalf("untargeted tombstone unexpectedly has a delivery path: %d", len(outboxes.Items)) + } + if !r.tombstoneAcknowledged(ctx, owner) { + t.Fatal("committed untargeted tombstone did not complete") + } + owner.Spec.WriterEpoch++ + if !r.tombstoneAcknowledged(ctx, owner) { + t.Fatal("takeover lost the committed zero-target withdrawal") + } +} diff --git a/internal/internaldns/controlplane/reconciler.go b/internal/internaldns/controlplane/reconciler.go index 98b947e..a6b87a3 100644 --- a/internal/internaldns/controlplane/reconciler.go +++ b/internal/internaldns/controlplane/reconciler.go @@ -120,42 +120,10 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: o.GetNamespace(), Name: "project"}}} }) controllerName := "internal-dns-control-plane-" + model.OpaqueToken(string(r.Options.ProjectUID))[:12] - b := ctrl.NewControllerManagedBy(mgr).Named(controllerName).For(&dnsv1alpha1.DNSResolverContext{}, builder.WithPredicates()). + b := ctrl.NewControllerManagedBy(mgr).Named(controllerName).For(&dnsv1alpha1.DNSRecordContribution{}, builder.WithPredicates()). Watches(&dnsv1alpha1.DNSZone{}, enqueue).Watches(&dnsv1alpha1.DNSZoneAssociation{}, enqueue). Watches(&dnsv1alpha1.DNSNamingPolicy{}, enqueue) - return b.Watches(&dnsv1alpha1.DNSResolverAccessBinding{}, enqueue).Complete(r) + return b.Watches(&dnsv1alpha1.DNSResolverContext{}, enqueue).Watches(&dnsv1alpha1.DNSResolverAccessBinding{}, enqueue).Complete(r) } - -func (r *Reconciler) reconcileProject(ctx context.Context, ns string) (ctrl.Result, error) { - now := r.Options.Now().UTC() - - if err := r.reconcileResolverContexts(ctx, ns, now); err != nil { - return ctrl.Result{}, err - } - - var zones dnsv1alpha1.DNSZoneList - var assocs dnsv1alpha1.DNSZoneAssociationList - var policies dnsv1alpha1.DNSNamingPolicyList - for _, item := range []client.ObjectList{&zones, &assocs, &policies} { - if err := r.List(ctx, item, client.InNamespace(ns)); err != nil { - return ctrl.Result{}, err - } - } - zoneByName := map[string]*dnsv1alpha1.DNSZone{} - for i := range zones.Items { - z := &zones.Items[i] - zoneByName[z.Name] = z - } - if err := r.reconcileAssociations(ctx, assocs.Items, zoneByName, now); err != nil { - return ctrl.Result{}, err - } - if err := r.reconcileNamingPolicies(ctx, policies.Items, assocs.Items, zoneByName, now); err != nil { - return ctrl.Result{}, err - } - if err := r.reconcileBindings(ctx, ns, assocs.Items, zoneByName, now); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{RequeueAfter: r.Options.LeaseDuration / 2}, nil -} diff --git a/internal/internaldns/controlplane/values.go b/internal/internaldns/controlplane/values.go index 011b19d..15075d6 100644 --- a/internal/internaldns/controlplane/values.go +++ b/internal/internaldns/controlplane/values.go @@ -3,3 +3,7 @@ package controlplane const dnsValueAccepted = "Accepted" +const dnsValueStaleRegistrationGeneration = "StaleRegistrationGeneration" +const dnsValueUnsupportedPrivateRecord = "UnsupportedPrivateRecord" + +const dnsValueTerminating = "Terminating" diff --git a/internal/internaldns/controlplane/wire.go b/internal/internaldns/controlplane/wire.go new file mode 100644 index 0000000..d6a281e --- /dev/null +++ b/internal/internaldns/controlplane/wire.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controlplane + +import ( + "fmt" + "sort" + "strings" + + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" + "go.miloapis.com/dns-operator/internal/cmd/dns/rdata" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +// WirePlan converts the compiler's API-rich records to the deliberately small, +// stable regional wire contract. +func WirePlan(plan PublicationPlan) (model.PublicationPlan, error) { + out := model.PublicationPlan{ZoneUID: string(plan.ZoneUID), Apex: plan.ZoneApex} + for _, registration := range plan.Registrations { + out.Registrations = append(out.Registrations, model.RegistrationFence{UID: string(registration.UID), Generation: uint64(registration.Generation)}) + } + for _, contribution := range plan.Contributions { + out.ObservationFences = append(out.ObservationFences, model.ObservationFence{ContributionUID: string(contribution.UID), Generation: uint64(contribution.Generation), Sequence: uint64(contribution.Sequence), Retired: contribution.Retired, SourceName: contribution.SourceName}) + } + sort.Slice(out.ObservationFences, func(i, j int) bool { + return out.ObservationFences[i].ContributionUID < out.ObservationFences[j].ContributionUID + }) + seenOwners := map[string]bool{} + for _, owner := range plan.Owners { + name := absoluteOwner(owner.Name, plan.ZoneApex) + if !seenOwners[name] { + out.Owners = append(out.Owners, name) + seenOwners[name] = true + } + } + sort.Strings(out.Owners) + for _, set := range plan.RRsets { + ttl := set.TTLSeconds + if ttl < 0 { + return model.PublicationPlan{}, fmt.Errorf("negative RRset TTL") + } + rr := model.RRSet{Name: absoluteOwner(set.Name, plan.ZoneApex), Type: string(set.RecordType), TTL: uint32(ttl)} + for i, entry := range set.Records { + content, err := recordContent(set.RecordType, entry) + if err != nil { + return model.PublicationPlan{}, err + } + record := model.RRRecord{Content: content, Eligible: true} + if i < len(set.ContributionUIDs) { + uid := set.ContributionUIDs[i] + record.ContributionUID = string(uid) + for _, f := range plan.Contributions { + if f.UID == uid { + record.Generation = uint64(f.Generation) + record.Sequence = uint64(f.Sequence) + break + } + } + } + rr.Records = append(rr.Records, record) + } + out.RRSets = append(out.RRSets, rr) + } + return out, out.Validate() +} + +func absoluteOwner(name, apex string) string { + name = canonicalName(name) + apex = canonicalName(apex) + if name == "@" { + return apex + } + if name == apex || strings.HasSuffix(name, "."+apex) { + return name + } + return name + "." + apex +} + +func recordContent(rt dnsv1alpha1.RRType, e dnsv1alpha1.RecordEntry) (string, error) { + content := rdata.Render(rt, e) + if content == "" { + return "", fmt.Errorf("record %q has no value matching type %s", e.Name, rt) + } + return content, nil +} diff --git a/internal/internaldns/controlplane/wire_test.go b/internal/internaldns/controlplane/wire_test.go new file mode 100644 index 0000000..ce196a1 --- /dev/null +++ b/internal/internaldns/controlplane/wire_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controlplane + +import ( + "fmt" + "strings" + "testing" + + "github.com/miekg/dns" + dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1" +) + +func TestRecordContentPreservesTypedRDATA(t *testing.T) { + tests := []struct { + name string + typeName dnsv1alpha1.RRType + entry dnsv1alpha1.RecordEntry + contains []string + }{ + { + name: "srv target", + typeName: dnsv1alpha1.RRTypeSRV, + entry: dnsv1alpha1.RecordEntry{Name: "_api._tcp", SRV: &dnsv1alpha1.SRVRecordSpec{Priority: 10, Weight: 20, Port: 8443, Target: "backend.example"}}, + contains: []string{"10 20 8443 backend.example."}, + }, + { + name: "svcb params", + typeName: dnsv1alpha1.RRTypeSVCB, + entry: dnsv1alpha1.RecordEntry{Name: "svc", SVCB: &dnsv1alpha1.HTTPSRecordSpec{Priority: 1, Target: "backend.example", Params: map[string]string{"alpn": "h2,h3", "port": "8443", "ech": "AEj+DQ=="}}}, + contains: []string{"alpn=h2,h3", "port=8443", `ech="AEj+DQ=="`}, + }, + { + name: "https params", + typeName: dnsv1alpha1.RRTypeHTTPS, + entry: dnsv1alpha1.RecordEntry{Name: "svc", HTTPS: &dnsv1alpha1.HTTPSRecordSpec{Priority: 1, Target: ".", Params: map[string]string{"no-default-alpn": "", "ipv4hint": "192.0.2.1,192.0.2.2"}}}, + contains: []string{"no-default-alpn", "ipv4hint=192.0.2.1,192.0.2.2"}, + }, + { + name: "txt special bytes", + typeName: dnsv1alpha1.RRTypeTXT, + entry: dnsv1alpha1.RecordEntry{Name: "txt", TXT: &dnsv1alpha1.TXTRecordSpec{Content: "quote=\" slash=\\ line=\n snowman=☃"}}, + contains: []string{`\"`, `\\`, `\010`, "☃"}, + }, + { + name: "caa quoting", + typeName: dnsv1alpha1.RRTypeCAA, + entry: dnsv1alpha1.RecordEntry{Name: "@", CAA: &dnsv1alpha1.CAARecordSpec{Flag: 0, Tag: "issue", Value: `ca.example; note="quoted" path=\root`}}, + contains: []string{`\;`, `\"quoted\"`, `\\root`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := recordContent(tt.typeName, tt.entry) + if err != nil { + t.Fatal(err) + } + for _, want := range tt.contains { + if !strings.Contains(content, want) { + t.Fatalf("content %q does not preserve %q", content, want) + } + } + if _, err := dns.NewRR(fmt.Sprintf("owner.example. 300 IN %s %s", tt.typeName, content)); err != nil { + t.Fatalf("rendered RDATA is not parseable: %q: %v", content, err) + } + }) + } +} diff --git a/internal/internaldns/model/model.go b/internal/internaldns/model/model.go index ad551c2..d4e7dfb 100644 --- a/internal/internaldns/model/model.go +++ b/internal/internaldns/model/model.go @@ -401,7 +401,7 @@ func (p PublicationPlan) Validate() error { owner := strings.ToLower(AbsoluteName(rr.Name)) rrType := strings.ToUpper(rr.Type) key := owner + "\x00" + rrType - if rr.TTL == 0 || len(rr.Records) == 0 || rr.Type == "" { + if len(rr.Records) == 0 || rr.Type == "" { return fmt.Errorf("RRset %q is incomplete", key) } if _, ok := dns.IsDomainName(owner); !ok || !dns.IsSubDomain(apex, owner) {