diff --git a/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml b/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml index 1a15e54..d7bef08 100644 --- a/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml +++ b/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml @@ -217,6 +217,13 @@ spec: recordSetNamespace: description: RecordSetNamespace is the owner RecordSet namespace. type: string + recordSetUID: + description: |- + RecordSetUID binds this item to one claim incarnation. It is optional on + the wire so pre-upgrade ledgers remain readable, not to authorize an + unbound provider mutation. Composition supplies the current claim UID. + minLength: 1 + type: string ttl: description: TTL is the record TTL in seconds for standard records. format: int32 @@ -457,8 +464,9 @@ spec: - type x-kubernetes-list-type: map deletionCompleted: - description: DeletionCompleted is true after provider-side record - cleanup has completed. + description: |- + DeletionCompleted is true after provider-side cleanup for RecordSetUID has + completed. Consumers must match its nonempty UID to the current claim. type: boolean observedGeneration: description: ObservedGeneration is the owner RecordSet generation @@ -488,6 +496,13 @@ spec: description: RecordSetNamespace is the owner RecordSet namespace matching spec.recordSets[]. type: string + recordSetUID: + description: |- + RecordSetUID identifies the claim incarnation observed by the provider. + Empty or mismatched UIDs cannot authorize cleanup or transfer provider + ownership to a recreated claim, even when name and generation match. + minLength: 1 + type: string required: - recordSetName - recordSetNamespace diff --git a/app/ui-mockup/src/mockData.ts b/app/ui-mockup/src/mockData.ts index ae75bc1..b034a6b 100644 --- a/app/ui-mockup/src/mockData.ts +++ b/app/ui-mockup/src/mockData.ts @@ -212,6 +212,7 @@ export const zoneUnits: ZoneUnit[] = [ { recordSetNamespace: 'apps', recordSetName: 'www-a', + recordSetUID: 'mock-record-uid', observedGeneration: 1, name: 'www', type: 'A', diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml index 1a15e54..d7bef08 100644 --- a/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml @@ -217,6 +217,13 @@ spec: recordSetNamespace: description: RecordSetNamespace is the owner RecordSet namespace. type: string + recordSetUID: + description: |- + RecordSetUID binds this item to one claim incarnation. It is optional on + the wire so pre-upgrade ledgers remain readable, not to authorize an + unbound provider mutation. Composition supplies the current claim UID. + minLength: 1 + type: string ttl: description: TTL is the record TTL in seconds for standard records. format: int32 @@ -457,8 +464,9 @@ spec: - type x-kubernetes-list-type: map deletionCompleted: - description: DeletionCompleted is true after provider-side record - cleanup has completed. + description: |- + DeletionCompleted is true after provider-side cleanup for RecordSetUID has + completed. Consumers must match its nonempty UID to the current claim. type: boolean observedGeneration: description: ObservedGeneration is the owner RecordSet generation @@ -488,6 +496,13 @@ spec: description: RecordSetNamespace is the owner RecordSet namespace matching spec.recordSets[]. type: string + recordSetUID: + description: |- + RecordSetUID identifies the claim incarnation observed by the provider. + Empty or mismatched UIDs cannot authorize cleanup or transfer provider + ownership to a recreated claim, even when name and generation match. + minLength: 1 + type: string required: - recordSetName - recordSetNamespace diff --git a/docs/design/core-api.md b/docs/design/core-api.md index 3b4a834..890b1cd 100644 --- a/docs/design/core-api.md +++ b/docs/design/core-api.md @@ -699,6 +699,7 @@ spec: - `recordSets`: accepted desired state for owner `RecordSet` claims. - `recordSets[].recordSetNamespace`: owner `RecordSet` namespace. This is part of the list-map key. - `recordSets[].recordSetName`: owner `RecordSet` name. This is part of the list-map key. +- `recordSets[].recordSetUID`: actual owner `RecordSet.metadata.uid`, supplied by core. A nonempty UID is required for provider mutation authority; namespace/name and generation alone do not identify an incarnation. - `recordSets[].observedGeneration`: `RecordSet.metadata.generation` used to build this item. - `recordSets[].name`: `RecordSet.spec.name` as a zone-relative DNS name, not an FQDN. - `recordSets[].type`: `RecordSet.spec.type`. @@ -706,7 +707,7 @@ spec: - `recordSets[].allowed`: omitted or `true` when the item is allowed by current composition policy. `false` means the item is retained only as an ownership and cleanup ledger after `Zone.spec.allowedRecordSets` changed. Provider controllers must not create or update provider-side records for `allowed: false` items unless `deletionRequested` is true. - `recordSets[].deletionRequested`: set by core when the owner `RecordSet` is deleting and provider-side cleanup is still needed. -`recordSets` is a Kubernetes `listType=map` keyed by scalar fields `recordSetNamespace` and `recordSetName`. `recordSetRef` is not used inside the list item because nested-object list-map keys are not a CRD-safe shape across Kubernetes versions. UID is not a key because provider controllers do not read `RecordSet` claims and therefore cannot rely on `RecordSet.metadata.uid`. When a `RecordSet` has not yet been restored during GitOps recovery, no new item is created for it. If an item already exists in a pre-applied `ZoneUnit`, core preserves it until the matching claim returns or provider cleanup completes. +`recordSets` is a Kubernetes `listType=map` keyed by scalar fields `recordSetNamespace` and `recordSetName`. `recordSetRef` is not used inside the list item because nested-object list-map keys are not a CRD-safe shape across Kubernetes versions. `recordSetUID` is an incarnation fence, not an additional list key: only one owner incarnation occupies a claim key at a time. Provider controllers do not read `RecordSet` claims; core supplies the UID from the actual claim. A pending predecessor remains its own cleanup item until its UID-bound cleanup completes. A recreated same-name claim cannot inherit the predecessor's provider payload or completion. `ZoneUnit.spec.zone` does not contain `Zone.metadata.uid`. Kubernetes assigns `metadata.uid`, so a `ZoneUnit` pre-applied for GitOps recovery cannot know the UID of a `Zone` claim that will be restored later. Provider controllers must not require the `Zone` UID as desired input. @@ -728,13 +729,29 @@ If multiple `RecordSet` claims target the same record identity, core keeps the e - `recordSets`: provider result for accepted owner `RecordSet` claims. - `recordSets[].recordSetNamespace`: owner `RecordSet` namespace matching `spec.recordSets[]`. - `recordSets[].recordSetName`: owner `RecordSet` name matching `spec.recordSets[]`. +- `recordSets[].recordSetUID`: exact nonempty UID of the observed spec item. Missing or different UIDs do not establish ownership or authorize claim finalization. - `recordSets[].observedGeneration`: owner `RecordSet` generation observed in `ZoneUnit.spec`. - `recordSets[].conditions`: provider acceptance and programming result for that `RecordSet` claim. - `recordSets[].provider.data`: public provider data to project to `RecordSet.status.provider.data`. - `recordSets[].provider.state`: optional provider-controller internal state for that record target. +- `recordSets[].deletionCompleted`: provider-confirmed cleanup completion for that exact UID, not a value derived from a previously positive `Programmed` condition. Provider controllers write `ZoneUnit.status` only. The Core ZoneUnit Controller projects `ZoneUnit.status.zone` to `Zone.status` and `ZoneUnit.status.recordSets[]` to each owner `RecordSet.status`. Claim status has `status.provider.data` for public provider data, but it does not expose `provider.state`. +Ownership and observation freshness are separate. A same-UID provider receipt survives an ordinary desired-generation update, so an owned record can be updated without adoption. Its old conditions do not become fresh: projection and aggregate readiness require both the receipt's `observedGeneration` and the individual condition's `observedGeneration` to match the desired item. Incompatible UID data is not projected to a recreated claim. + +Record-set status writers preserve unrelated entries and use optimistic resource-version preconditions instead of replaying cached whole arrays. Immediately before an external record mutation, providers also validate the complete current source and its Kubernetes resource version. A cached source that predates a replacement, generation update, or policy revocation cannot authorize that mutation. This pre-dispatch fence is not an atomic transaction spanning Kubernetes and the external DNS API; normal no-op observations do not issue provider writes or mutation-fence patches. + +Route 53 pending affected-record entries include the claim UID. A legacy or predecessor pending change may still be polled and cleared, but cannot attest a successor. `INSYNC` confirms the submitted batch, not a newer desired generation; the controller lists and plans current provider state before publishing a current positive observation. + +**UID cutover and legacy state.** + +The UID fields are optional on the wire so existing ledgers remain readable. Explicit empty strings are invalid in the CRD; omitted or mismatched UIDs still fail closed in controllers. There is no namespace/name/generation fallback and no automatic relabeling of old provider state. + +This is not a transparent in-place upgrade for existing UID-less ownership receipts. Apply the updated CRDs before adopting the corresponding controllers; Helm does not update existing CRDs from a chart's `crds/` directory during a normal upgrade. Qualify the coordinated controller/schema cutover and recovery of existing claims separately. + +Already-present external records with unbound ownership remain conflicts. Recovery uses separately authorized, provider-validated explicit `RecordSet.spec.adoption`, or a fresh provider observation establishing absence before normal creation or cleanup. Desired-value equality alone does not adopt a foreign Cloudflare record ID. Do not patch UIDs, provider status, or finalizers to manufacture ownership, and do not enable zone-wide adoption as an automatic fallback. The source correction neither restores already-lost live authority nor authorizes release or deployment. + Provider-specific status follows a public-data boundary: - `provider.data` contains only provider-specific values that help the user operate the resource outside dns-api. Examples include external IDs that can be copied into adoption settings, used to open a provider dashboard, or used in support and troubleshooting workflows. diff --git a/internal/go/core/controllers/zoneunit/recordset_incarnation_test.go b/internal/go/core/controllers/zoneunit/recordset_incarnation_test.go new file mode 100644 index 0000000..7c6aea7 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/recordset_incarnation_test.go @@ -0,0 +1,243 @@ +package zoneunit + +import ( + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestZoneUnitCompositionReconcilerDoesNotFinalizeDeletingRecordSetFromPredecessorOrLegacyCompletion(t *testing.T) { + for _, testCase := range []struct { + name string + completionUID types.UID + }{ + {name: "predecessor UID", completionUID: types.UID("predecessor-recordset")}, + {name: "missing UID"}, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx := t.Context() + claim := zone("app", "apps-example-com", "apps.example.com") + claim.Spec.ZoneClassRef.Namespace = ptr("platform") + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.UID = types.UID("current-recordset") + deletionTime := metav1.NewTime(time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)) + recordSet.DeletionTimestamp = &deletionTime + recordSet.Finalizers = []string{coreRecordSetFinalizer} + recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: []byte(`{"source":"stale"}`)}} + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: dnsv1alpha1.ZoneUnitSpec{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + }, + }, + Status: dnsv1alpha1.ZoneUnitStatus{RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: testCase.completionUID, + DeletionCompleted: true, + Provider: &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: []byte(`{"source":"stale"}`)}}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }}}, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), claim, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + + if _, err := (&ZoneUnitCompositionReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(claim)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var retained dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &retained); err != nil { + t.Fatalf("deleting RecordSet was removed using %s completion evidence: %v", testCase.name, err) + } + if len(retained.Finalizers) != 1 || retained.Finalizers[0] != coreRecordSetFinalizer { + t.Fatalf("deleting RecordSet finalizers = %v, want %q retained", retained.Finalizers, coreRecordSetFinalizer) + } + if retained.Status.Provider != nil { + t.Fatalf("RecordSet status.provider = %#v, want stale provider state cleared", retained.Status.Provider) + } + assertCondition(t, retained.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionUnknown, "OwnerStateNotResolved") + assertCondition(t, retained.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") + }) + } +} + +func TestZoneUnitCompositionReconcilerFinalizesDeletingRecordSetFromCurrentUIDCompletion(t *testing.T) { + ctx := t.Context() + claim := zone("app", "apps-example-com", "apps.example.com") + claim.Spec.ZoneClassRef.Namespace = ptr("platform") + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.UID = types.UID("current-recordset") + deletionTime := metav1.NewTime(time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)) + recordSet.DeletionTimestamp = &deletionTime + recordSet.Finalizers = []string{coreRecordSetFinalizer} + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: dnsv1alpha1.ZoneUnitSpec{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + }, + }, + Status: dnsv1alpha1.ZoneUnitStatus{RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: recordSet.UID, + DeletionCompleted: true, + }}}, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), claim, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + + if _, err := (&ZoneUnitCompositionReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(claim)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var finalized dnsv1alpha1.RecordSet + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &finalized) + if apierrors.IsNotFound(err) { + return + } + if err != nil { + t.Fatalf("RecordSet get returned error: %v", err) + } + if len(finalized.Finalizers) != 0 { + t.Fatalf("deleting RecordSet finalizers = %v, want none", finalized.Finalizers) + } +} + +func TestZoneUnitCompositionReconcilerReplacesCompletedPredecessorWithoutProjectingItsState(t *testing.T) { + ctx := t.Context() + claim := zone("app", "apps-example-com", "apps.example.com") + claim.Spec.ZoneClassRef.Namespace = ptr("platform") + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.UID = types.UID("current-recordset") + recordSet.Spec.Options = raw(t, map[string]any{"source": "current"}) + recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: []byte(`{"source":"predecessor"}`)}} + predecessor := recordSet.DeepCopy() + predecessor.UID = types.UID("predecessor-recordset") + deletionTime := metav1.NewTime(time.Date(2026, 9, 9, 11, 0, 0, 0, time.UTC)) + predecessor.DeletionTimestamp = &deletionTime + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: dnsv1alpha1.ZoneUnitSpec{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(predecessor, providercontract.Payload{Options: raw(t, map[string]any{"source": "predecessor"})}), + }, + }, + Status: dnsv1alpha1.ZoneUnitStatus{RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: predecessor.UID, + DeletionCompleted: true, + Provider: &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: []byte(`{"source":"predecessor"}`)}}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }}}, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), claim, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + + if _, err := (&ZoneUnitCompositionReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(claim)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want one current item", gotUnit.Spec.RecordSets) + } + item := gotUnit.Spec.RecordSets[0] + if item.RecordSetUID != recordSet.UID { + t.Fatalf("recordSetUID = %q, want current UID %q", item.RecordSetUID, recordSet.UID) + } + assertRawEqual(t, item.Options, map[string]any{"source": "current"}) + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + if gotRecordSet.Status.Provider != nil { + t.Fatalf("RecordSet status.provider = %#v, want predecessor state cleared", gotRecordSet.Status.Provider) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionUnknown, "OwnerStateNotResolved") + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") +} + +func TestZoneUnitCompositionReconcilerRetainsPendingPredecessorBeforeReplacement(t *testing.T) { + ctx := t.Context() + claim := zone("app", "apps-example-com", "apps.example.com") + claim.Spec.ZoneClassRef.Namespace = ptr("platform") + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.UID = types.UID("current-recordset") + predecessor := recordSet.DeepCopy() + predecessor.UID = types.UID("predecessor-recordset") + deletionTime := metav1.NewTime(time.Date(2026, 9, 9, 11, 0, 0, 0, time.UTC)) + predecessor.DeletionTimestamp = &deletionTime + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: dnsv1alpha1.ZoneUnitSpec{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(predecessor, providercontract.Payload{Options: raw(t, map[string]any{"source": "predecessor"})}), + }, + }, + Status: dnsv1alpha1.ZoneUnitStatus{RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: predecessor.UID, + }}}, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), claim, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + + if _, err := (&ZoneUnitCompositionReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(claim)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want retained predecessor", gotUnit.Spec.RecordSets) + } + item := gotUnit.Spec.RecordSets[0] + if item.RecordSetUID != predecessor.UID || !item.DeletionRequested { + t.Fatalf("recordSet item = %#v, want pending predecessor", item) + } + assertRawEqual(t, item.Options, map[string]any{"source": "predecessor"}) + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") +} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_controller.go b/internal/go/core/controllers/zoneunit/zoneunit_controller.go index 67920cb..47c4047 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_controller.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_controller.go @@ -11,16 +11,13 @@ import ( "github.com/appthrust/dns-api/internal/go/core/providercontract" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" "github.com/google/cel-go/cel" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( @@ -172,10 +169,12 @@ func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl. if err != nil { return ctrl.Result{}, err } + pendingPredecessorItems := pendingPredecessorRecordSetItems(recordSets, existing.Spec.RecordSets, providerStatusesByRef) retainedItems, err := r.retainedNotAllowedRecordSetItems(ctx, policyRejected, existing.Spec.RecordSets, providerStatusesByRef) if err != nil { return ctrl.Result{}, err } + items = append(pendingPredecessorItems, items...) items = append(items, retainedItems...) zoneStatus := recordSetZoneStatus(&zone, zoneClass) rejected := append(policyRejected, providerMismatchRejected...) @@ -270,18 +269,6 @@ func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } -func (r *ZoneUnitCompositionReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - Named("zoneunit-composition"). - For(&dnsv1alpha1.Zone{}). - Watches(&dnsv1alpha1.RecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapRecordSetToZone)). - Watches(&dnsv1alpha1.ZoneUnit{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneUnitToZone)). - Watches(&dnsv1alpha1.ZoneClass{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneClassToZones)). - Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapProviderToZones)). - Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.mapNamespaceToZones)). - Complete(r) -} - func (r *ZoneUnitCompositionReconciler) recordSetsForZoneKey(ctx context.Context, key client.ObjectKey) ([]dnsv1alpha1.RecordSet, error) { var list dnsv1alpha1.RecordSetList if err := r.List(ctx, &list); err != nil { @@ -400,6 +387,7 @@ func recordSetsSupportedByProvider(recordSets []dnsv1alpha1.RecordSet, provider } func (r *ZoneUnitCompositionReconciler) acceptedRecordSetItems(ctx context.Context, recordSets []dnsv1alpha1.RecordSet, existingItems []dnsv1alpha1.ZoneUnitRecordSetSpec, providerStatusesByRef map[string]dnsv1alpha1.ZoneUnitRecordSetStatus, provider *dnsv1alpha1.Provider, storageVersion *dnsv1alpha1.ProviderVersion, zone *dnsv1alpha1.Zone) ([]dnsv1alpha1.ZoneUnitRecordSetSpec, []rejectedRecordSet, error) { + existingItems = retainedExistingRecordSetItems(recordSets, existingItems, providerStatusesByRef) existingByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetSpec{} for _, item := range existingItems { existingByRef[zoneUnitRecordSetItemKey(item)] = item @@ -466,8 +454,10 @@ func (r *ZoneUnitCompositionReconciler) acceptedRecordSetItems(ctx context.Conte } item := zoneUnitRecordSetItem(recordSet, convertedPayload) refKey := recordSetClaimKey(recordSet.Namespace, recordSet.Name) + existing, existingFound := existingByRef[refKey] + predecessorDeletionPending := existingFound && zoneUnitRecordSetItemIsPendingPredecessor(existing, recordSet) if !recordSet.DeletionTimestamp.IsZero() { - if providerRecordSetDeletionCompleted(providerStatusesByRef[refKey]) { + if providerRecordSetDeletionCompleted(providerStatusesByRef[refKey], recordSet.UID) { if err := r.removeFinalizer(ctx, recordSet, coreRecordSetFinalizer); err != nil { return nil, nil, err } @@ -476,6 +466,15 @@ func (r *ZoneUnitCompositionReconciler) acceptedRecordSetItems(ctx context.Conte } else if err := r.ensureFinalizer(ctx, recordSet, coreRecordSetFinalizer); err != nil { return nil, nil, err } + if predecessorDeletionPending { + rejected = append(rejected, rejectedRecordSet{ + recordSet: recordSet, + status: metav1.ConditionFalse, + reason: "RecordSetConflict", + message: recordSetConflictMessage(zone, item, zoneUnitRecordSetItemKey(existing)), + }) + continue + } if recordSet.DeletionTimestamp.IsZero() { if existing, ok := existingByRef[refKey]; ok && !zoneUnitRecordSetSpecEqual(existing, item) { rejected = append(rejected, rejectedRecordSet{ @@ -513,6 +512,43 @@ func (r *ZoneUnitCompositionReconciler) acceptedRecordSetItems(ctx context.Conte return items, rejected, nil } +func retainedExistingRecordSetItems(recordSets []dnsv1alpha1.RecordSet, existingItems []dnsv1alpha1.ZoneUnitRecordSetSpec, providerStatusesByRef map[string]dnsv1alpha1.ZoneUnitRecordSetStatus) []dnsv1alpha1.ZoneUnitRecordSetSpec { + recordSetsByRef := map[string]*dnsv1alpha1.RecordSet{} + for index := range recordSets { + recordSet := &recordSets[index] + recordSetsByRef[recordSetClaimKey(recordSet.Namespace, recordSet.Name)] = recordSet + } + items := make([]dnsv1alpha1.ZoneUnitRecordSetSpec, 0, len(existingItems)) + for _, item := range existingItems { + refKey := zoneUnitRecordSetItemKey(item) + if item.DeletionRequested && providerRecordSetDeletionCompleted(providerStatusesByRef[refKey], item.RecordSetUID) { + continue + } + recordSet, currentClaim := recordSetsByRef[refKey] + if currentClaim && !zoneUnitRecordSetItemMatchesRecordSet(item, recordSet) && (!item.DeletionRequested || item.RecordSetUID == "") { + continue + } + items = append(items, item) + } + return items +} + +func pendingPredecessorRecordSetItems(recordSets []dnsv1alpha1.RecordSet, existingItems []dnsv1alpha1.ZoneUnitRecordSetSpec, providerStatusesByRef map[string]dnsv1alpha1.ZoneUnitRecordSetStatus) []dnsv1alpha1.ZoneUnitRecordSetSpec { + recordSetsByRef := map[string]*dnsv1alpha1.RecordSet{} + for index := range recordSets { + recordSet := &recordSets[index] + recordSetsByRef[recordSetClaimKey(recordSet.Namespace, recordSet.Name)] = recordSet + } + items := make([]dnsv1alpha1.ZoneUnitRecordSetSpec, 0) + for _, item := range existingItems { + recordSet, currentClaim := recordSetsByRef[zoneUnitRecordSetItemKey(item)] + if currentClaim && zoneUnitRecordSetItemIsPendingPredecessor(item, recordSet) && !providerRecordSetDeletionCompleted(providerStatusesByRef[zoneUnitRecordSetItemKey(item)], item.RecordSetUID) { + items = append(items, item) + } + } + return items +} + func (r *ZoneUnitCompositionReconciler) retainedNotAllowedRecordSetItems(ctx context.Context, rejected []rejectedRecordSet, existingItems []dnsv1alpha1.ZoneUnitRecordSetSpec, providerStatusesByRef map[string]dnsv1alpha1.ZoneUnitRecordSetStatus) ([]dnsv1alpha1.ZoneUnitRecordSetSpec, error) { existingByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetSpec{} for _, item := range existingItems { @@ -526,7 +562,7 @@ func (r *ZoneUnitCompositionReconciler) retainedNotAllowedRecordSetItems(ctx con if !ok { continue } - if !recordSet.DeletionTimestamp.IsZero() && providerRecordSetDeletionCompleted(providerStatusesByRef[refKey]) { + if !recordSet.DeletionTimestamp.IsZero() && providerRecordSetDeletionCompleted(providerStatusesByRef[refKey], recordSet.UID) { if err := r.removeFinalizer(ctx, recordSet, coreRecordSetFinalizer); err != nil { return nil, err } @@ -537,6 +573,17 @@ func (r *ZoneUnitCompositionReconciler) retainedNotAllowedRecordSetItems(ctx con return nil, err } } + if !zoneUnitRecordSetItemMatchesRecordSet(existing, recordSet) { + if zoneUnitRecordSetItemIsPendingPredecessor(existing, recordSet) && !providerRecordSetDeletionCompleted(providerStatusesByRef[refKey], existing.RecordSetUID) { + continue + } + if !recordSet.DeletionTimestamp.IsZero() { + item := zoneUnitRecordSetItem(recordSet, providercontract.Payload{}) + item.Allowed = boolPtr(false) + items = append(items, item) + } + continue + } item := existing item.Allowed = boolPtr(false) item.DeletionRequested = !recordSet.DeletionTimestamp.IsZero() @@ -557,13 +604,15 @@ func pendingRecordSetDeletionItems(recordSets []dnsv1alpha1.RecordSet, existingB continue } refKey := recordSetClaimKey(recordSet.Namespace, recordSet.Name) - if existing, ok := existingByRef[refKey]; ok { + if existing, ok := existingByRef[refKey]; ok && zoneUnitRecordSetItemMatchesRecordSet(existing, recordSet) { items = append(items, existing) continue } items = append(items, dnsv1alpha1.ZoneUnitRecordSetSpec{ RecordSetNamespace: recordSet.Namespace, RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, Name: recordSet.Spec.Name, Type: recordSet.Spec.Type, }) @@ -575,7 +624,7 @@ func pendingRecordSetDeletion(recordSet *dnsv1alpha1.RecordSet, providerStatuses if recordSet.DeletionTimestamp.IsZero() { return false } - return !providerRecordSetDeletionCompleted(providerStatusesByRef[recordSetClaimKey(recordSet.Namespace, recordSet.Name)]) + return !providerRecordSetDeletionCompleted(providerStatusesByRef[recordSetClaimKey(recordSet.Namespace, recordSet.Name)], recordSet.UID) } func zoneUnitItemOwnedByPendingDeletion(item dnsv1alpha1.ZoneUnitRecordSetSpec, refKey string, pendingItems []dnsv1alpha1.ZoneUnitRecordSetSpec, providerVersion *dnsv1alpha1.ProviderVersion, zone *dnsv1alpha1.Zone, provider *dnsv1alpha1.Provider) bool { @@ -678,6 +727,7 @@ func zoneUnitRecordSetItem(recordSet *dnsv1alpha1.RecordSet, payload providercon item := dnsv1alpha1.ZoneUnitRecordSetSpec{ RecordSetNamespace: recordSet.Namespace, RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, ObservedGeneration: recordSet.Generation, Name: recordSet.Spec.Name, Type: recordSet.Spec.Type, @@ -698,6 +748,22 @@ func zoneUnitRecordSetItem(recordSet *dnsv1alpha1.RecordSet, payload providercon return item } +func zoneUnitRecordSetItemMatchesRecordSet(item dnsv1alpha1.ZoneUnitRecordSetSpec, recordSet *dnsv1alpha1.RecordSet) bool { + return recordSetUIDsMatch(item.RecordSetUID, recordSet.UID) +} + +func zoneUnitRecordSetItemIsPendingPredecessor(item dnsv1alpha1.ZoneUnitRecordSetSpec, recordSet *dnsv1alpha1.RecordSet) bool { + return item.DeletionRequested && item.RecordSetUID != "" && recordSet.UID != "" && item.RecordSetUID != recordSet.UID +} + +func zoneUnitRecordSetStatusMatchesItem(status dnsv1alpha1.ZoneUnitRecordSetStatus, item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return recordSetUIDsMatch(status.RecordSetUID, item.RecordSetUID) +} + +func recordSetUIDsMatch(a, b types.UID) bool { + return a != "" && b != "" && a == b +} + func zoneUnitRecordSetSpecEqual(a, b dnsv1alpha1.ZoneUnitRecordSetSpec) bool { a.ObservedGeneration = b.ObservedGeneration a.Allowed = b.Allowed @@ -711,361 +777,3 @@ func conversionObject(value interface{}) map[string]interface{} { } return object } - -func (r *ZoneUnitCompositionReconciler) applyZoneUnitDesired(ctx context.Context, desired *dnsv1alpha1.ZoneUnit, create bool) error { - if create { - desired.TypeMeta = metav1.TypeMeta{APIVersion: dnsv1alpha1.SchemeGroupVersion.String(), Kind: "ZoneUnit"} - return client.IgnoreAlreadyExists(r.Create(ctx, desired, client.FieldOwner(coreZoneUnitFieldOwner))) - } - var current dnsv1alpha1.ZoneUnit - if err := r.Get(ctx, client.ObjectKeyFromObject(desired), ¤t); err != nil { - return err - } - base := current.DeepCopy() - current.Spec = desired.Spec - if !slices.Contains(current.Finalizers, coreZoneUnitFinalizer) && current.DeletionTimestamp.IsZero() { - current.Finalizers = append(current.Finalizers, coreZoneUnitFinalizer) - } - syncZoneUnitReconcileRequestAnnotation(¤t, desired) - if equality.Semantic.DeepEqual(base.Spec, current.Spec) && - equality.Semantic.DeepEqual(base.Annotations, current.Annotations) && - equality.Semantic.DeepEqual(base.Finalizers, current.Finalizers) { - return nil - } - return r.Patch(ctx, ¤t, client.MergeFrom(base), client.FieldOwner(coreZoneUnitFieldOwner)) -} - -func (r *ZoneUnitCompositionReconciler) ensureFinalizer(ctx context.Context, obj client.Object, finalizer string) error { - if slices.Contains(obj.GetFinalizers(), finalizer) || !obj.GetDeletionTimestamp().IsZero() { - return nil - } - base := obj.DeepCopyObject().(client.Object) - obj.SetFinalizers(append(obj.GetFinalizers(), finalizer)) - return r.Patch(ctx, obj, client.MergeFrom(base)) -} - -func (r *ZoneUnitCompositionReconciler) removeFinalizer(ctx context.Context, obj client.Object, finalizer string) error { - if !slices.Contains(obj.GetFinalizers(), finalizer) { - return nil - } - base := obj.DeepCopyObject().(client.Object) - obj.SetFinalizers(slices.DeleteFunc(obj.GetFinalizers(), func(value string) bool { - return value == finalizer - })) - return client.IgnoreNotFound(r.Patch(ctx, obj, client.MergeFrom(base))) -} - -func (r *ZoneUnitCompositionReconciler) removeZoneUnitCoreFinalizer(ctx context.Context, unit *dnsv1alpha1.ZoneUnit) error { - return r.removeFinalizer(ctx, unit, coreZoneUnitFinalizer) -} - -func providerZoneCleanupCompleted(unit *dnsv1alpha1.ZoneUnit) bool { - for _, finalizer := range unit.Finalizers { - if finalizer != coreZoneUnitFinalizer { - return false - } - } - return true -} - -func providerRecordSetDeletionCompleted(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.DeletionCompleted -} - -func syncZoneUnitReconcileRequestAnnotation(unit *dnsv1alpha1.ZoneUnit, source client.Object) { - sourceAnnotations := source.GetAnnotations() - value, ok := sourceAnnotations[reconcileRequestAnnotation] - if !ok { - if unit.Annotations != nil { - delete(unit.Annotations, reconcileRequestAnnotation) - if len(unit.Annotations) == 0 { - unit.Annotations = nil - } - } - return - } - if unit.Annotations == nil { - unit.Annotations = map[string]string{} - } - unit.Annotations[reconcileRequestAnnotation] = value -} - -func (r *ZoneUnitCompositionReconciler) projectZoneUnitStatus(ctx context.Context, zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass, recordSets []dnsv1alpha1.RecordSet, unit *dnsv1alpha1.ZoneUnit) error { - if unit.Status.Zone != nil { - accepted := projectedAccepted(zone.Status.Conditions, unit.Status.Zone.Conditions) - programmed := projectedProgrammed(accepted.Status, unit.Status.Zone.Conditions) - if err := r.setZoneClaimStatus(ctx, zone, accepted.Status, accepted.Reason, programmed.Status, programmed.Reason, unit.Status.Zone); err != nil { - return err - } - } - statusByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetStatus{} - desiredByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetSpec{} - for _, item := range unit.Spec.RecordSets { - desiredByRef[zoneUnitRecordSetItemKey(item)] = item - } - for _, item := range unit.Status.RecordSets { - refKey := zoneUnitRecordSetStatusKey(item) - desiredItem, ok := desiredByRef[refKey] - if !ok || retainedNotAllowedRecordSetItem(desiredItem) || staleCompletedDeletionStatusForActiveItem(item, desiredItem) { - continue - } - statusByRef[refKey] = item - } - for index := range recordSets { - recordSet := &recordSets[index] - providerStatus, ok := statusByRef[recordSetClaimKey(recordSet.Namespace, recordSet.Name)] - if !ok { - continue - } - accepted := projectedAccepted(recordSet.Status.Conditions, providerStatus.Conditions) - programmed := projectedProgrammed(accepted.Status, providerStatus.Conditions) - if err := r.setRecordSetClaimStatusWithZone(ctx, recordSet, accepted.Status, accepted.Reason, programmed.Status, programmed.Reason, recordSetZoneStatus(zone, zoneClass), &providerStatus); err != nil { - return err - } - } - return nil -} - -func staleCompletedDeletionStatusForActiveItem(status dnsv1alpha1.ZoneUnitRecordSetStatus, item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { - return status.DeletionCompleted && !item.DeletionRequested -} - -func retainedNotAllowedRecordSetItem(item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { - return !item.IsAllowed() && !item.DeletionRequested -} - -type projectedCondition struct { - Status metav1.ConditionStatus - Reason string -} - -func projectedAccepted(compositionConditions, providerConditions []metav1.Condition) projectedCondition { - provider := meta.FindStatusCondition(providerConditions, string(dnsv1alpha1.ConditionAccepted)) - if provider != nil { - return projectedCondition{Status: provider.Status, Reason: provider.Reason} - } - composition := meta.FindStatusCondition(compositionConditions, string(dnsv1alpha1.ConditionAccepted)) - if composition != nil && composition.Status != metav1.ConditionTrue { - return projectedCondition{Status: composition.Status, Reason: composition.Reason} - } - return projectedCondition{Status: metav1.ConditionUnknown, Reason: "OwnerStateNotResolved"} -} - -func projectedProgrammed(accepted metav1.ConditionStatus, providerConditions []metav1.Condition) projectedCondition { - if accepted != metav1.ConditionTrue { - return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling"} - } - provider := meta.FindStatusCondition(providerConditions, string(dnsv1alpha1.ConditionProgrammed)) - if provider == nil { - return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling"} - } - return projectedCondition{Status: provider.Status, Reason: provider.Reason} -} - -func (r *ZoneUnitCompositionReconciler) writeRecordSetsWaiting(ctx context.Context, recordSets []dnsv1alpha1.RecordSet, reason string) error { - for index := range recordSets { - if err := r.setRecordSetClaimStatus(ctx, &recordSets[index], metav1.ConditionUnknown, reason, metav1.ConditionUnknown, "Reconciling", nil); err != nil { - return err - } - } - return nil -} - -func (r *ZoneUnitCompositionReconciler) writeRecordSetsNotAccepted(ctx context.Context, recordSets []dnsv1alpha1.RecordSet, reason string) error { - for index := range recordSets { - if err := r.setRecordSetClaimStatus(ctx, &recordSets[index], metav1.ConditionFalse, reason, metav1.ConditionUnknown, "Reconciling", nil); err != nil { - return err - } - } - return nil -} - -func (r *ZoneUnitCompositionReconciler) setZoneClaimStatus(ctx context.Context, zone *dnsv1alpha1.Zone, acceptedStatus metav1.ConditionStatus, acceptedReason string, programmedStatus metav1.ConditionStatus, programmedReason string, providerStatus *dnsv1alpha1.ZoneUnitZoneStatus) error { - return r.setZoneClaimStatusWithMessages(ctx, zone, acceptedStatus, acceptedReason, acceptedReason, programmedStatus, programmedReason, programmedReason, providerStatus) -} - -func (r *ZoneUnitCompositionReconciler) setZoneClaimStatusWithMessages(ctx context.Context, zone *dnsv1alpha1.Zone, acceptedStatus metav1.ConditionStatus, acceptedReason, acceptedMessage string, programmedStatus metav1.ConditionStatus, programmedReason, programmedMessage string, providerStatus *dnsv1alpha1.ZoneUnitZoneStatus) error { - base := zone.DeepCopy() - zone.Status.ObservedGeneration = zone.Generation - meta.SetStatusCondition(&zone.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionAccepted), Status: acceptedStatus, Reason: acceptedReason, Message: acceptedMessage, ObservedGeneration: zone.Generation}) - meta.SetStatusCondition(&zone.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionProgrammed), Status: programmedStatus, Reason: programmedReason, Message: programmedMessage, ObservedGeneration: zone.Generation}) - if providerStatus != nil { - zone.Status.NameServers = slices.Clone(providerStatus.NameServers) - if providerStatus.Provider != nil && len(providerStatus.Provider.Data.Raw) > 0 { - zone.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: providerStatus.Provider.Data} - } else { - zone.Status.Provider = nil - } - } - if equality.Semantic.DeepEqual(base.Status, zone.Status) { - return nil - } - return client.IgnoreNotFound(r.Status().Patch(ctx, zone, client.MergeFrom(base))) -} - -func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason string, programmedStatus metav1.ConditionStatus, programmedReason string, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { - return r.setRecordSetClaimStatusWithMessages(ctx, recordSet, acceptedStatus, acceptedReason, acceptedReason, programmedStatus, programmedReason, programmedReason, providerStatus) -} - -func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatusWithZone(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason string, programmedStatus metav1.ConditionStatus, programmedReason string, zoneStatus *dnsv1alpha1.RecordSetZoneStatus, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { - return r.setRecordSetClaimStatusWithZoneAndMessages(ctx, recordSet, acceptedStatus, acceptedReason, acceptedReason, programmedStatus, programmedReason, programmedReason, zoneStatus, providerStatus) -} - -func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatusWithMessages(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason, acceptedMessage string, programmedStatus metav1.ConditionStatus, programmedReason, programmedMessage string, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { - return r.setRecordSetClaimStatusWithZoneAndMessages(ctx, recordSet, acceptedStatus, acceptedReason, acceptedMessage, programmedStatus, programmedReason, programmedMessage, nil, providerStatus) -} - -func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatusWithZoneAndMessages(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason, acceptedMessage string, programmedStatus metav1.ConditionStatus, programmedReason, programmedMessage string, zoneStatus *dnsv1alpha1.RecordSetZoneStatus, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { - base := recordSet.DeepCopy() - recordSet.Status.ObservedGeneration = recordSet.Generation - if zoneStatus != nil { - recordSet.Status.Zone = zoneStatus - } - meta.SetStatusCondition(&recordSet.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionAccepted), Status: acceptedStatus, Reason: acceptedReason, Message: acceptedMessage, ObservedGeneration: recordSet.Generation}) - meta.SetStatusCondition(&recordSet.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionProgrammed), Status: programmedStatus, Reason: programmedReason, Message: programmedMessage, ObservedGeneration: recordSet.Generation}) - if providerStatus != nil && providerStatus.Provider != nil && len(providerStatus.Provider.Data.Raw) > 0 { - recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: providerStatus.Provider.Data} - } else if providerStatus != nil { - recordSet.Status.Provider = nil - } - if equality.Semantic.DeepEqual(base.Status, recordSet.Status) { - return nil - } - return client.IgnoreNotFound(r.Status().Patch(ctx, recordSet, client.MergeFrom(base))) -} - -func recordSetZoneStatus(zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass) *dnsv1alpha1.RecordSetZoneStatus { - if zone == nil { - return nil - } - return &dnsv1alpha1.RecordSetZoneStatus{ - Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, - } -} - -func (r *ZoneUnitCompositionReconciler) mapRecordSetToZone(_ context.Context, obj client.Object) []reconcile.Request { - recordSet, ok := obj.(*dnsv1alpha1.RecordSet) - if !ok { - return nil - } - namespace, name := recordSetZoneKey(recordSet) - return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: namespace, Name: name}}} -} - -func (r *ZoneUnitCompositionReconciler) mapZoneUnitToZone(_ context.Context, obj client.Object) []reconcile.Request { - unit, ok := obj.(*dnsv1alpha1.ZoneUnit) - if !ok { - return nil - } - return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: unit.Spec.Zone.Ref.Namespace, Name: unit.Spec.Zone.Ref.Name}}} -} - -func (r *ZoneUnitCompositionReconciler) mapZoneClassToZones(ctx context.Context, obj client.Object) []reconcile.Request { - zoneClass, ok := obj.(*dnsv1alpha1.ZoneClass) - if !ok { - return nil - } - var zones dnsv1alpha1.ZoneList - if err := r.List(ctx, &zones); err != nil { - return nil - } - requests := make([]reconcile.Request, 0) - for _, zone := range zones.Items { - namespace := zone.Namespace - if zone.Spec.ZoneClassRef.Namespace != nil && *zone.Spec.ZoneClassRef.Namespace != "" { - namespace = *zone.Spec.ZoneClassRef.Namespace - } - if namespace == zoneClass.Namespace && zone.Spec.ZoneClassRef.Name == zoneClass.Name { - requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&zone)}) - } - } - return requests -} - -func (r *ZoneUnitCompositionReconciler) mapProviderToZones(ctx context.Context, obj client.Object) []reconcile.Request { - provider, ok := obj.(*dnsv1alpha1.Provider) - if !ok { - return nil - } - var zones dnsv1alpha1.ZoneList - if err := r.List(ctx, &zones); err != nil { - return nil - } - requests := make([]reconcile.Request, 0) - for _, zone := range zones.Items { - if zone.Spec.Provider.Name == provider.Name { - requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&zone)}) - } - } - return requests -} - -func (r *ZoneUnitCompositionReconciler) mapNamespaceToZones(ctx context.Context, obj client.Object) []reconcile.Request { - namespace, ok := obj.(*corev1.Namespace) - if !ok { - return nil - } - seen := map[client.ObjectKey]struct{}{} - var requests []reconcile.Request - appendRequest := func(key client.ObjectKey) { - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - requests = append(requests, reconcile.Request{NamespacedName: key}) - } - - var zones dnsv1alpha1.ZoneList - if err := r.List(ctx, &zones); err != nil { - return nil - } - for _, zone := range zones.Items { - if zone.Namespace == namespace.Name { - appendRequest(client.ObjectKeyFromObject(&zone)) - } - } - - var recordSets dnsv1alpha1.RecordSetList - if err := r.List(ctx, &recordSets); err != nil { - return requests - } - for _, recordSet := range recordSets.Items { - if recordSet.Namespace != namespace.Name { - continue - } - zoneNamespace, zoneName := recordSetZoneKey(&recordSet) - appendRequest(client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}) - } - return requests -} - -func recordSetClaimKey(namespace, name string) string { - return namespace + "\x00" + name -} - -func recordSetZoneKey(recordSet *dnsv1alpha1.RecordSet) (string, string) { - namespace := recordSet.Namespace - if recordSet.Spec.ZoneRef.Namespace != nil && *recordSet.Spec.ZoneRef.Namespace != "" { - namespace = *recordSet.Spec.ZoneRef.Namespace - } - return namespace, recordSet.Spec.ZoneRef.Name -} - -func zoneUnitRecordSetItemKey(item dnsv1alpha1.ZoneUnitRecordSetSpec) string { - return recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) -} - -func zoneUnitRecordSetStatusKey(item dnsv1alpha1.ZoneUnitRecordSetStatus) string { - return recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) -} - -func compareString(a, b string) int { - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 - } -} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go b/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go index b4825c0..697cae6 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "testing" - "time" "github.com/appthrust/dns-api/internal/go/core/providercontract" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" @@ -14,757 +13,12 @@ import ( "k8s.io/apimachinery/pkg/api/meta" 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 TestZoneUnitCompositionReconcilerCreatesRecordSetItemsWithScalarClaimKey(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not created: %v", err) - } - if len(unit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want one item", unit.Spec.RecordSets) - } - item := unit.Spec.RecordSets[0] - if item.RecordSetNamespace != "app" || item.RecordSetName != "www-a" { - t.Fatalf("recordSet key = %q/%q, want app/www-a", item.RecordSetNamespace, item.RecordSetName) - } - if item.Name != "www" || item.Type != dnsv1alpha1.RecordTypeA { - t.Fatalf("record identity = %q/%q, want www/A", item.Name, item.Type) - } -} - -func TestZoneUnitCompositionReconcilerConvertsProviderPayloadsToStorageVersion(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Spec.Provider.Version = "v1beta1" - zone.Spec.Adoption = raw(t, map[string]any{"zoneID": "ZINPUT"}) - recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - recordSet.Spec.Provider.Version = "v1beta1" - recordSet.Spec.Options = raw(t, map[string]any{"alias": map[string]any{"dnsName": "target.example.com."}}) - zoneClass := route53ZoneClassForZoneUnitTest() - zoneClass.Spec.Provider.Version = "v1beta1" - provider := route53ProviderForZoneUnitTest() - provider.Spec.Versions = append(provider.Spec.Versions, dnsv1alpha1.ProviderVersion{ - Name: "v1beta1", - Served: true, - Zone: dnsv1alpha1.ProviderZone{ - Conversion: dnsv1alpha1.ProviderConversion{ - ToStorage: dnsv1alpha1.ProviderConversionTarget{ - CEL: "{'adoption': {'storageZoneID': self.adoption.zoneID}}", - }, - }, - }, - RecordSet: dnsv1alpha1.ProviderRecordSet{ - SupportedTypes: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}, - Conversion: dnsv1alpha1.ProviderConversion{ - ToStorage: dnsv1alpha1.ProviderConversionTarget{ - CEL: "{'options': {'storageAlias': self.options.alias}}", - }, - }, - }, - }) - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(provider, zoneClass, zone, recordSet). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { - var gotZone dnsv1alpha1.Zone - if getErr := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotZone); getErr != nil { - t.Fatalf("ZoneUnit was not created: %v; Zone get error: %v", err, getErr) - } - t.Fatalf("ZoneUnit was not created: %v; Zone status: %#v", err, gotZone.Status) - } - if unit.Spec.Provider.Version != "v1alpha1" { - t.Fatalf("ZoneUnit provider version = %q, want storage version v1alpha1", unit.Spec.Provider.Version) - } - assertRawEqual(t, unit.Spec.Zone.Adoption, map[string]any{"storageZoneID": "ZINPUT"}) - if len(unit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want one item", unit.Spec.RecordSets) - } - assertRawEqual(t, unit.Spec.RecordSets[0].Options, map[string]any{ - "storageAlias": map[string]any{"dnsName": "target.example.com."}, - }) -} - -func TestZoneUnitCompositionReconcilerCopiesZoneReconcileRequestAnnotation(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Annotations = map[string]string{ - reconcileRequestAnnotation: "2026-06-09T09:00:00Z", - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not created: %v", err) - } - if got := unit.Annotations[reconcileRequestAnnotation]; got != "2026-06-09T09:00:00Z" { - t.Fatalf("ZoneUnit reconcile annotation = %q, want copied request", got) - } -} - -func TestZoneUnitCompositionReconcilerUpdatesReconcileRequestAnnotationWithoutSpecChange(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Annotations = map[string]string{ - reconcileRequestAnnotation: "2026-06-09T09:05:00Z", - } - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "app", - Name: "apps-example-com", - Annotations: map[string]string{ - reconcileRequestAnnotation: "2026-06-09T09:00:00Z", - }, - }, - Spec: zoneUnitSpecForZoneUnitTest(zone), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, unit). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var got dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if got.Annotations[reconcileRequestAnnotation] != "2026-06-09T09:05:00Z" { - t.Fatalf("ZoneUnit reconcile annotation = %q, want updated request", got.Annotations[reconcileRequestAnnotation]) - } - if !equality.Semantic.DeepEqual(got.Spec, unit.Spec) { - t.Fatalf("ZoneUnit spec changed: %#v", got.Spec) - } -} - -func TestZoneUnitCompositionReconcilerRejectsRecordSetsNotAllowedByZone(t *testing.T) { - ctx := context.Background() - zone := zone("zone-ns", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ - { - Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ - Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, - }, - Records: []dnsv1alpha1.AllowedRecord{ - {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, - }, - }, - } - recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - route53ProviderForZoneUnitTest(), - route53ZoneClassForZoneUnitTest(), - zone, - recordSet, - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns"}}, - ). - WithStatusSubresource(&dnsv1alpha1.RecordSet{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "zone-ns", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not created: %v", err) - } - if len(unit.Spec.RecordSets) != 0 { - t.Fatalf("recordSets = %#v, want no accepted items", unit.Spec.RecordSets) - } - var got dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &got); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - condition := assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "NotAllowedByZone") - if condition.Message != "RecordSet namespace is not allowed by the referenced Zone." { - t.Fatalf("Accepted message = %q", condition.Message) - } - assertRecordSetZoneStatus(t, got.Status.Zone, "zone-ns", "apps-example-com") -} - -func TestZoneUnitCompositionReconcilerRetainsExistingRecordSetNotAllowedByZone(t *testing.T) { - ctx := context.Background() - zone := zone("zone-ns", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ - { - Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ - Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, - }, - Records: []dnsv1alpha1.AllowedRecord{ - {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, - }, - }, - } - recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") - recordSet.Finalizers = []string{coreRecordSetFinalizer} - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{Namespace: "zone-ns", Name: "apps-example-com"}, - Spec: zoneUnitSpecForZoneUnitTest(zone), - Status: dnsv1alpha1.ZoneUnitStatus{ - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: "record-ns", - RecordSetName: "www-a", - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - }, - }, - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - route53ProviderForZoneUnitTest(), - route53ZoneClassForZoneUnitTest(), - zone, - recordSet, - unit, - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns"}}, - ). - WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if len(gotUnit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want retained item", gotUnit.Spec.RecordSets) - } - item := gotUnit.Spec.RecordSets[0] - if item.RecordSetNamespace != "record-ns" || item.RecordSetName != "www-a" { - t.Fatalf("recordSet item = %#v, want record-ns/www-a", item) - } - if item.IsAllowed() { - t.Fatalf("recordSet item allowed = true, want false: %#v", item) - } - if item.DeletionRequested { - t.Fatalf("recordSet item deletionRequested = true, want false") - } - - var gotRecordSet dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "NotAllowedByZone") -} - -func TestZoneUnitCompositionReconcilerRequestsDeletionForRetainedNotAllowedRecordSet(t *testing.T) { - ctx := context.Background() - zone := zone("zone-ns", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ - { - Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ - Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, - }, - Records: []dnsv1alpha1.AllowedRecord{ - {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, - }, - }, - } - recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") - deletionTime := metav1.NewTime(time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC)) - recordSet.DeletionTimestamp = &deletionTime - recordSet.Finalizers = []string{coreRecordSetFinalizer} - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{Namespace: "zone-ns", Name: "apps-example-com"}, - Spec: zoneUnitSpecForZoneUnitTest(zone), - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - route53ProviderForZoneUnitTest(), - route53ZoneClassForZoneUnitTest(), - zone, - recordSet, - unit, - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns"}}, - ). - WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if len(gotUnit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want retained deleting item", gotUnit.Spec.RecordSets) - } - item := gotUnit.Spec.RecordSets[0] - if item.IsAllowed() { - t.Fatalf("recordSet item allowed = true, want false: %#v", item) - } - if !item.DeletionRequested { - t.Fatalf("recordSet item deletionRequested = false, want true") - } -} - -func TestZoneUnitCompositionReconcilerAcceptsRecordSetsAllowedByZone(t *testing.T) { - ctx := context.Background() - zone := zone("zone-ns", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ - { - Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ - Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, - }, - Records: []dnsv1alpha1.AllowedRecord{ - {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, - }, - }, - } - recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects( - route53ProviderForZoneUnitTest(), - route53ZoneClassForZoneUnitTest(), - zone, - recordSet, - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns", Labels: map[string]string{"access": "dns"}}}, - ). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "zone-ns", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not created: %v", err) - } - if len(unit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want one accepted item", unit.Spec.RecordSets) - } - if unit.Spec.RecordSets[0].RecordSetNamespace != "record-ns" || unit.Spec.RecordSets[0].RecordSetName != "www-a" { - t.Fatalf("recordSet key = %q/%q, want record-ns/www-a", unit.Spec.RecordSets[0].RecordSetNamespace, unit.Spec.RecordSets[0].RecordSetName) - } -} - -func TestZoneUnitCompositionReconcilerKeepsDeletingRecordSetUntilProviderDeletionCompletes(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) - recordSet.DeletionTimestamp = &deletionTime - recordSet.Finalizers = []string{coreRecordSetFinalizer} - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, - Spec: zoneUnitSpecForZoneUnitTest(zone), - Status: dnsv1alpha1.ZoneUnitStatus{ - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: "app", - RecordSetName: "www-a", - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - }, - }, - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). - WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var got dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if len(got.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want deleting item", got.Spec.RecordSets) - } - item := got.Spec.RecordSets[0] - if item.RecordSetNamespace != "app" || item.RecordSetName != "www-a" || !item.DeletionRequested { - t.Fatalf("recordSet item = %#v, want app/www-a with deletionRequested", item) - } -} - -func TestZoneUnitCompositionReconcilerDoesNotDeleteZoneUnitWhileZoneStillHasRecordSets(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) - zone.DeletionTimestamp = &deletionTime - zone.Finalizers = []string{coreZoneFinalizer} - recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "app", - Name: "apps-example-com", - Finalizers: []string{coreZoneUnitFinalizer, "route53.dns.appthrust.io/zoneunit-finalizer"}, - }, - Spec: zoneUnitSpecForZoneUnitTest(zone), - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if !gotUnit.DeletionTimestamp.IsZero() { - t.Fatalf("ZoneUnit deletionTimestamp = %v, want not deleting", gotUnit.DeletionTimestamp) - } - if len(gotUnit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want one remaining item", gotUnit.Spec.RecordSets) - } - var gotZone dnsv1alpha1.Zone - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotZone); err != nil { - t.Fatalf("Zone was not found: %v", err) - } - condition := assertCondition(t, gotZone.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") - if condition.Message != "1 RecordSet resources still reference this Zone; delete or move them before Zone cleanup can continue" { - t.Fatalf("Programmed message = %q", condition.Message) - } -} - -func TestZoneUnitCompositionReconcilerCreatesZoneUnitForDeletingZoneWithRecordSets(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) - zone.DeletionTimestamp = &deletionTime - zone.Finalizers = []string{coreZoneFinalizer} - recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not created: %v", err) - } - if !gotUnit.DeletionTimestamp.IsZero() { - t.Fatalf("ZoneUnit deletionTimestamp = %v, want not deleting", gotUnit.DeletionTimestamp) - } - if len(gotUnit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want one remaining item", gotUnit.Spec.RecordSets) - } -} - -func TestZoneUnitCompositionReconcilerRemovesZoneFinalizerWhenDeletingZoneHasNoZoneUnitOrRecordSets(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) - zone.DeletionTimestamp = &deletionTime - zone.Finalizers = []string{coreZoneFinalizer} - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotZone dnsv1alpha1.Zone - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotZone); err != nil { - if apierrors.IsNotFound(err) { - return - } - t.Fatalf("Zone get returned error: %v", err) - } - if len(gotZone.Finalizers) != 0 { - t.Fatalf("Zone finalizers = %#v, want none", gotZone.Finalizers) - } -} - -func TestZoneUnitCompositionReconcilerKeepsZoneUnitActiveForDeletingRecordSetsWhileZoneDeletes(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) - zone.DeletionTimestamp = &deletionTime - zone.Finalizers = []string{coreZoneFinalizer} - recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - recordSet.DeletionTimestamp = &deletionTime - recordSet.Finalizers = []string{coreRecordSetFinalizer} - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "app", - Name: "apps-example-com", - Finalizers: []string{coreZoneUnitFinalizer, "route53.dns.appthrust.io/zoneunit-finalizer"}, - }, - Spec: zoneUnitSpecForZoneUnitTest(zone), - Status: dnsv1alpha1.ZoneUnitStatus{ - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: "app", - RecordSetName: "www-a", - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - }, - }, - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if !gotUnit.DeletionTimestamp.IsZero() { - t.Fatalf("ZoneUnit deletionTimestamp = %v, want not deleting", gotUnit.DeletionTimestamp) - } - if len(gotUnit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want deleting item", gotUnit.Spec.RecordSets) - } - if !gotUnit.Spec.RecordSets[0].DeletionRequested { - t.Fatalf("recordSet item = %#v, want deletionRequested", gotUnit.Spec.RecordSets[0]) - } -} - -func TestZoneUnitCompositionReconcilerPrioritizesDeletingRecordSetOverNewConflictingRecordSet(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - deletingA := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) - deletingA.DeletionTimestamp = &deletionTime - deletingA.Finalizers = []string{coreRecordSetFinalizer} - cname := cnameRecordSet("app", "www-cname", "app", "apps-example-com", "www") - provider := route53ProviderForZoneUnitTest() - provider.Spec.Versions[0].RecordSet.SupportedTypes = append(provider.Spec.Versions[0].RecordSet.SupportedTypes, dnsv1alpha1.RecordTypeCNAME) - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, - Spec: zoneUnitSpecForZoneUnitTest(zone), - Status: dnsv1alpha1.ZoneUnitStatus{ - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: "app", - RecordSetName: "www-a", - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - }, - }, - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(cname, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(provider, route53ZoneClassForZoneUnitTest(), zone, deletingA, cname, unit). - WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - if len(gotUnit.Spec.RecordSets) != 1 { - t.Fatalf("recordSets = %#v, want only deleting A item", gotUnit.Spec.RecordSets) - } - item := gotUnit.Spec.RecordSets[0] - if item.RecordSetName != "www-a" || item.Type != dnsv1alpha1.RecordTypeA || !item.DeletionRequested { - t.Fatalf("recordSet item = %#v, want deleting A item", item) - } - - var gotCNAME dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cname), &gotCNAME); err != nil { - t.Fatalf("CNAME RecordSet was not found: %v", err) - } - accepted := assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") - if accepted.Message != "RecordSet type=CNAME name=www in Zone app/apps-example-com conflicts with owner RecordSet app/www-a." { - t.Fatalf("Accepted message = %q", accepted.Message) - } - programmed := assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") - if programmed.Message != recordSetNotAcceptedProgrammedMessage { - t.Fatalf("Programmed message = %q", programmed.Message) - } -} - -func TestZoneUnitCompositionReconcilerDoesNotProjectStaleDeletedRecordSetStatusToRecreatedRecordSet(t *testing.T) { - ctx := context.Background() - zone := zone("app", "apps-example-com", "apps.example.com") - zone.Spec.ZoneClassRef.Namespace = ptr("platform") - recreatedA := aRecordSet("app", "www-a", "app", "apps-example-com", "www") - cname := cnameRecordSet("app", "www-cname", "app", "apps-example-com", "www") - provider := route53ProviderForZoneUnitTest() - provider.Spec.Versions[0].RecordSet.SupportedTypes = append(provider.Spec.Versions[0].RecordSet.SupportedTypes, dnsv1alpha1.RecordTypeCNAME) - unit := &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, - Spec: zoneUnitSpecForZoneUnitTest(zone), - Status: dnsv1alpha1.ZoneUnitStatus{ - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: "app", - RecordSetName: "www-a", - ObservedGeneration: 2, - DeletionCompleted: true, - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - { - RecordSetNamespace: "app", - RecordSetName: "www-cname", - ObservedGeneration: cname.Generation, - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - }, - }, - } - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - zoneUnitRecordSetItem(cname, providercontract.Payload{}), - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(provider, route53ZoneClassForZoneUnitTest(), zone, recreatedA, cname, unit). - WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} - - if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotA dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recreatedA), &gotA); err != nil { - t.Fatalf("A RecordSet was not found: %v", err) - } - accepted := assertCondition(t, gotA.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") - if accepted.Message != "RecordSet type=A name=www in Zone app/apps-example-com conflicts with owner RecordSet app/www-cname." { - t.Fatalf("Accepted message = %q", accepted.Message) - } - programmed := assertCondition(t, gotA.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") - if programmed.Message != recordSetNotAcceptedProgrammedMessage { - t.Fatalf("Programmed message = %q", programmed.Message) - } - - var gotCNAME dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cname), &gotCNAME); err != nil { - t.Fatalf("CNAME RecordSet was not found: %v", err) - } - assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") - assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") -} - func TestZoneUnitCompositionReconcilerAllowsNonCNAMERecordSetsAtSameName(t *testing.T) { ctx := context.Background() zone := zone("app", "apps-example-com", "apps.example.com") @@ -1097,6 +351,7 @@ func TestZoneUnitCompositionReconcilerProjectsRecordSetStatusByScalarClaimKey(t { RecordSetNamespace: "app", RecordSetName: "www-a", + RecordSetUID: recordSet.UID, Conditions: []metav1.Condition{ {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, @@ -1198,6 +453,7 @@ func TestZoneUnitCompositionReconcilerClearsStaleClaimProviderStatus(t *testing. { RecordSetNamespace: "app", RecordSetName: "www-a", + RecordSetUID: recordSet.UID, Conditions: []metav1.Condition{ {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, @@ -1261,6 +517,7 @@ func TestZoneUnitCompositionReconcilerProjectsRecordSetProviderDataOnly(t *testi { RecordSetNamespace: "app", RecordSetName: "www-a", + RecordSetUID: recordSet.UID, Provider: &dnsv1alpha1.ProviderStatus{ Data: runtime.RawExtension{Raw: []byte(`{"records":[{"id":"023e105f4ecef8ad9ca31a8372d0c010"}]}`)}, State: runtime.RawExtension{Raw: []byte(`{"records":[{"id":"023e105f4ecef8ad9ca31a8372d0c010","content":"192.0.2.10"}]}`)}, @@ -1435,7 +692,7 @@ func cloudflareRecordSet(recordSet *dnsv1alpha1.RecordSet) *dnsv1alpha1.RecordSe func aRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dnsv1alpha1.RecordSet { return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: recordSetUID(namespace, name)}, Spec: dnsv1alpha1.RecordSetSpec{ ZoneRef: dnsv1alpha1.ZoneReference{ Namespace: ptr(zoneNamespace), @@ -1452,7 +709,7 @@ func aRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dns func cnameRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dnsv1alpha1.RecordSet { return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: recordSetUID(namespace, name)}, Spec: dnsv1alpha1.RecordSetSpec{ ZoneRef: dnsv1alpha1.ZoneReference{ Namespace: ptr(zoneNamespace), @@ -1469,7 +726,7 @@ func cnameRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) func txtRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dnsv1alpha1.RecordSet { return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: recordSetUID(namespace, name)}, Spec: dnsv1alpha1.RecordSetSpec{ ZoneRef: dnsv1alpha1.ZoneReference{ Namespace: ptr(zoneNamespace), @@ -1486,7 +743,7 @@ func txtRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *d func mxRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dnsv1alpha1.RecordSet { return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: recordSetUID(namespace, name)}, Spec: dnsv1alpha1.RecordSetSpec{ ZoneRef: dnsv1alpha1.ZoneReference{ Namespace: ptr(zoneNamespace), @@ -1503,7 +760,7 @@ func mxRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dn func caaRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dnsv1alpha1.RecordSet { return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: recordSetUID(namespace, name)}, Spec: dnsv1alpha1.RecordSetSpec{ ZoneRef: dnsv1alpha1.ZoneReference{ Namespace: ptr(zoneNamespace), @@ -1520,7 +777,7 @@ func caaRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *d func nsRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dnsv1alpha1.RecordSet { return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: recordSetUID(namespace, name)}, Spec: dnsv1alpha1.RecordSetSpec{ ZoneRef: dnsv1alpha1.ZoneReference{ Namespace: ptr(zoneNamespace), @@ -1535,6 +792,10 @@ func nsRecordSet(namespace, name, zoneNamespace, zoneName, ownerName string) *dn } } +func recordSetUID(namespace, name string) types.UID { + return types.UID(namespace + "-" + name + "-uid") +} + func ptr[T any](value T) *T { return &value } diff --git a/internal/go/core/controllers/zoneunit/zoneunit_lifecycle.go b/internal/go/core/controllers/zoneunit/zoneunit_lifecycle.go new file mode 100644 index 0000000..0eaf5d7 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/zoneunit_lifecycle.go @@ -0,0 +1,90 @@ +package zoneunit + +import ( + "context" + "slices" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *ZoneUnitCompositionReconciler) applyZoneUnitDesired(ctx context.Context, desired *dnsv1alpha1.ZoneUnit, create bool) error { + if create { + desired.TypeMeta = metav1.TypeMeta{APIVersion: dnsv1alpha1.SchemeGroupVersion.String(), Kind: "ZoneUnit"} + return client.IgnoreAlreadyExists(r.Create(ctx, desired, client.FieldOwner(coreZoneUnitFieldOwner))) + } + var current dnsv1alpha1.ZoneUnit + if err := r.Get(ctx, client.ObjectKeyFromObject(desired), ¤t); err != nil { + return err + } + base := current.DeepCopy() + current.Spec = desired.Spec + if !slices.Contains(current.Finalizers, coreZoneUnitFinalizer) && current.DeletionTimestamp.IsZero() { + current.Finalizers = append(current.Finalizers, coreZoneUnitFinalizer) + } + syncZoneUnitReconcileRequestAnnotation(¤t, desired) + if equality.Semantic.DeepEqual(base.Spec, current.Spec) && + equality.Semantic.DeepEqual(base.Annotations, current.Annotations) && + equality.Semantic.DeepEqual(base.Finalizers, current.Finalizers) { + return nil + } + return r.Patch(ctx, ¤t, client.MergeFrom(base), client.FieldOwner(coreZoneUnitFieldOwner)) +} + +func (r *ZoneUnitCompositionReconciler) ensureFinalizer(ctx context.Context, obj client.Object, finalizer string) error { + if slices.Contains(obj.GetFinalizers(), finalizer) || !obj.GetDeletionTimestamp().IsZero() { + return nil + } + base := obj.DeepCopyObject().(client.Object) + obj.SetFinalizers(append(obj.GetFinalizers(), finalizer)) + return r.Patch(ctx, obj, client.MergeFrom(base)) +} + +func (r *ZoneUnitCompositionReconciler) removeFinalizer(ctx context.Context, obj client.Object, finalizer string) error { + if !slices.Contains(obj.GetFinalizers(), finalizer) { + return nil + } + base := obj.DeepCopyObject().(client.Object) + obj.SetFinalizers(slices.DeleteFunc(obj.GetFinalizers(), func(value string) bool { + return value == finalizer + })) + return client.IgnoreNotFound(r.Patch(ctx, obj, client.MergeFrom(base))) +} + +func (r *ZoneUnitCompositionReconciler) removeZoneUnitCoreFinalizer(ctx context.Context, unit *dnsv1alpha1.ZoneUnit) error { + return r.removeFinalizer(ctx, unit, coreZoneUnitFinalizer) +} + +func providerZoneCleanupCompleted(unit *dnsv1alpha1.ZoneUnit) bool { + for _, finalizer := range unit.Finalizers { + if finalizer != coreZoneUnitFinalizer { + return false + } + } + return true +} + +func providerRecordSetDeletionCompleted(status dnsv1alpha1.ZoneUnitRecordSetStatus, recordSetUID types.UID) bool { + return status.DeletionCompleted && recordSetUIDsMatch(status.RecordSetUID, recordSetUID) +} + +func syncZoneUnitReconcileRequestAnnotation(unit *dnsv1alpha1.ZoneUnit, source client.Object) { + sourceAnnotations := source.GetAnnotations() + value, ok := sourceAnnotations[reconcileRequestAnnotation] + if !ok { + if unit.Annotations != nil { + delete(unit.Annotations, reconcileRequestAnnotation) + if len(unit.Annotations) == 0 { + unit.Annotations = nil + } + } + return + } + if unit.Annotations == nil { + unit.Annotations = map[string]string{} + } + unit.Annotations[reconcileRequestAnnotation] = value +} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_reconcile_test.go b/internal/go/core/controllers/zoneunit/zoneunit_reconcile_test.go new file mode 100644 index 0000000..afe0c52 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/zoneunit_reconcile_test.go @@ -0,0 +1,776 @@ +package zoneunit + +import ( + "context" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/client/fake" +) + +func TestZoneUnitCompositionReconcilerCreatesRecordSetItemsWithScalarClaimKey(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not created: %v", err) + } + if len(unit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want one item", unit.Spec.RecordSets) + } + item := unit.Spec.RecordSets[0] + if item.RecordSetNamespace != "app" || item.RecordSetName != "www-a" { + t.Fatalf("recordSet key = %q/%q, want app/www-a", item.RecordSetNamespace, item.RecordSetName) + } + if item.RecordSetUID != recordSet.UID { + t.Fatalf("recordSetUID = %q, want claim UID %q", item.RecordSetUID, recordSet.UID) + } + if item.Name != "www" || item.Type != dnsv1alpha1.RecordTypeA { + t.Fatalf("record identity = %q/%q, want www/A", item.Name, item.Type) + } +} + +func TestZoneUnitCompositionReconcilerConvertsProviderPayloadsToStorageVersion(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Spec.Provider.Version = "v1beta1" + zone.Spec.Adoption = raw(t, map[string]any{"zoneID": "ZINPUT"}) + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.Spec.Provider.Version = "v1beta1" + recordSet.Spec.Options = raw(t, map[string]any{"alias": map[string]any{"dnsName": "target.example.com."}}) + zoneClass := route53ZoneClassForZoneUnitTest() + zoneClass.Spec.Provider.Version = "v1beta1" + provider := route53ProviderForZoneUnitTest() + provider.Spec.Versions = append(provider.Spec.Versions, dnsv1alpha1.ProviderVersion{ + Name: "v1beta1", + Served: true, + Zone: dnsv1alpha1.ProviderZone{ + Conversion: dnsv1alpha1.ProviderConversion{ + ToStorage: dnsv1alpha1.ProviderConversionTarget{ + CEL: "{'adoption': {'storageZoneID': self.adoption.zoneID}}", + }, + }, + }, + RecordSet: dnsv1alpha1.ProviderRecordSet{ + SupportedTypes: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}, + Conversion: dnsv1alpha1.ProviderConversion{ + ToStorage: dnsv1alpha1.ProviderConversionTarget{ + CEL: "{'options': {'storageAlias': self.options.alias}}", + }, + }, + }, + }) + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(provider, zoneClass, zone, recordSet). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { + var gotZone dnsv1alpha1.Zone + if getErr := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotZone); getErr != nil { + t.Fatalf("ZoneUnit was not created: %v; Zone get error: %v", err, getErr) + } + t.Fatalf("ZoneUnit was not created: %v; Zone status: %#v", err, gotZone.Status) + } + if unit.Spec.Provider.Version != "v1alpha1" { + t.Fatalf("ZoneUnit provider version = %q, want storage version v1alpha1", unit.Spec.Provider.Version) + } + assertRawEqual(t, unit.Spec.Zone.Adoption, map[string]any{"storageZoneID": "ZINPUT"}) + if len(unit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want one item", unit.Spec.RecordSets) + } + assertRawEqual(t, unit.Spec.RecordSets[0].Options, map[string]any{ + "storageAlias": map[string]any{"dnsName": "target.example.com."}, + }) +} + +func TestZoneUnitCompositionReconcilerCopiesZoneReconcileRequestAnnotation(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Annotations = map[string]string{ + reconcileRequestAnnotation: "2026-06-09T09:00:00Z", + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not created: %v", err) + } + if got := unit.Annotations[reconcileRequestAnnotation]; got != "2026-06-09T09:00:00Z" { + t.Fatalf("ZoneUnit reconcile annotation = %q, want copied request", got) + } +} + +func TestZoneUnitCompositionReconcilerUpdatesReconcileRequestAnnotationWithoutSpecChange(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Annotations = map[string]string{ + reconcileRequestAnnotation: "2026-06-09T09:05:00Z", + } + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "app", + Name: "apps-example-com", + Annotations: map[string]string{ + reconcileRequestAnnotation: "2026-06-09T09:00:00Z", + }, + }, + Spec: zoneUnitSpecForZoneUnitTest(zone), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, unit). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if got.Annotations[reconcileRequestAnnotation] != "2026-06-09T09:05:00Z" { + t.Fatalf("ZoneUnit reconcile annotation = %q, want updated request", got.Annotations[reconcileRequestAnnotation]) + } + if !equality.Semantic.DeepEqual(got.Spec, unit.Spec) { + t.Fatalf("ZoneUnit spec changed: %#v", got.Spec) + } +} + +func TestZoneUnitCompositionReconcilerRejectsRecordSetsNotAllowedByZone(t *testing.T) { + ctx := context.Background() + zone := zone("zone-ns", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ + { + Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, + }, + Records: []dnsv1alpha1.AllowedRecord{ + {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, + }, + }, + } + recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + route53ProviderForZoneUnitTest(), + route53ZoneClassForZoneUnitTest(), + zone, + recordSet, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns"}}, + ). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "zone-ns", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not created: %v", err) + } + if len(unit.Spec.RecordSets) != 0 { + t.Fatalf("recordSets = %#v, want no accepted items", unit.Spec.RecordSets) + } + var got dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &got); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + condition := assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "NotAllowedByZone") + if condition.Message != "RecordSet namespace is not allowed by the referenced Zone." { + t.Fatalf("Accepted message = %q", condition.Message) + } + assertRecordSetZoneStatus(t, got.Status.Zone, "zone-ns", "apps-example-com") +} + +func TestZoneUnitCompositionReconcilerRetainsExistingRecordSetNotAllowedByZone(t *testing.T) { + ctx := context.Background() + zone := zone("zone-ns", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ + { + Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, + }, + Records: []dnsv1alpha1.AllowedRecord{ + {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, + }, + }, + } + recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") + recordSet.Finalizers = []string{coreRecordSetFinalizer} + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "zone-ns", Name: "apps-example-com"}, + Spec: zoneUnitSpecForZoneUnitTest(zone), + Status: dnsv1alpha1.ZoneUnitStatus{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "record-ns", + RecordSetName: "www-a", + RecordSetUID: recordSet.UID, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + }, + }, + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + route53ProviderForZoneUnitTest(), + route53ZoneClassForZoneUnitTest(), + zone, + recordSet, + unit, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns"}}, + ). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want retained item", gotUnit.Spec.RecordSets) + } + item := gotUnit.Spec.RecordSets[0] + if item.RecordSetNamespace != "record-ns" || item.RecordSetName != "www-a" { + t.Fatalf("recordSet item = %#v, want record-ns/www-a", item) + } + if item.IsAllowed() { + t.Fatalf("recordSet item allowed = true, want false: %#v", item) + } + if item.DeletionRequested { + t.Fatalf("recordSet item deletionRequested = true, want false") + } + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "NotAllowedByZone") +} + +func TestZoneUnitCompositionReconcilerRequestsDeletionForRetainedNotAllowedRecordSet(t *testing.T) { + ctx := context.Background() + zone := zone("zone-ns", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ + { + Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, + }, + Records: []dnsv1alpha1.AllowedRecord{ + {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, + }, + }, + } + recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") + deletionTime := metav1.NewTime(time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC)) + recordSet.DeletionTimestamp = &deletionTime + recordSet.Finalizers = []string{coreRecordSetFinalizer} + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "zone-ns", Name: "apps-example-com"}, + Spec: zoneUnitSpecForZoneUnitTest(zone), + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + route53ProviderForZoneUnitTest(), + route53ZoneClassForZoneUnitTest(), + zone, + recordSet, + unit, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns"}}, + ). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want retained deleting item", gotUnit.Spec.RecordSets) + } + item := gotUnit.Spec.RecordSets[0] + if item.RecordSetUID != recordSet.UID { + t.Fatalf("recordSetUID = %q, want claim UID %q", item.RecordSetUID, recordSet.UID) + } + if item.IsAllowed() { + t.Fatalf("recordSet item allowed = true, want false: %#v", item) + } + if !item.DeletionRequested { + t.Fatalf("recordSet item deletionRequested = false, want true") + } +} + +func TestZoneUnitCompositionReconcilerAcceptsRecordSetsAllowedByZone(t *testing.T) { + ctx := context.Background() + zone := zone("zone-ns", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Spec.AllowedRecordSets = []dnsv1alpha1.AllowedRecordSet{ + { + Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, + }, + Records: []dnsv1alpha1.AllowedRecord{ + {Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}, + }, + }, + } + recordSet := aRecordSet("record-ns", "www-a", "zone-ns", "apps-example-com", "www") + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects( + route53ProviderForZoneUnitTest(), + route53ZoneClassForZoneUnitTest(), + zone, + recordSet, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns", Labels: map[string]string{"access": "dns"}}}, + ). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "zone-ns", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not created: %v", err) + } + if len(unit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want one accepted item", unit.Spec.RecordSets) + } + if unit.Spec.RecordSets[0].RecordSetNamespace != "record-ns" || unit.Spec.RecordSets[0].RecordSetName != "www-a" { + t.Fatalf("recordSet key = %q/%q, want record-ns/www-a", unit.Spec.RecordSets[0].RecordSetNamespace, unit.Spec.RecordSets[0].RecordSetName) + } +} + +func TestZoneUnitCompositionReconcilerKeepsDeletingRecordSetUntilProviderDeletionCompletes(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) + recordSet.DeletionTimestamp = &deletionTime + recordSet.Finalizers = []string{coreRecordSetFinalizer} + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: zoneUnitSpecForZoneUnitTest(zone), + Status: dnsv1alpha1.ZoneUnitStatus{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: recordSet.UID, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + }, + }, + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if len(got.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want deleting item", got.Spec.RecordSets) + } + item := got.Spec.RecordSets[0] + if item.RecordSetNamespace != "app" || item.RecordSetName != "www-a" || !item.DeletionRequested { + t.Fatalf("recordSet item = %#v, want app/www-a with deletionRequested", item) + } +} + +func TestZoneUnitCompositionReconcilerDoesNotDeleteZoneUnitWhileZoneStillHasRecordSets(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) + zone.DeletionTimestamp = &deletionTime + zone.Finalizers = []string{coreZoneFinalizer} + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "app", + Name: "apps-example-com", + Finalizers: []string{coreZoneUnitFinalizer, "route53.dns.appthrust.io/zoneunit-finalizer"}, + }, + Spec: zoneUnitSpecForZoneUnitTest(zone), + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if !gotUnit.DeletionTimestamp.IsZero() { + t.Fatalf("ZoneUnit deletionTimestamp = %v, want not deleting", gotUnit.DeletionTimestamp) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want one remaining item", gotUnit.Spec.RecordSets) + } + var gotZone dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotZone); err != nil { + t.Fatalf("Zone was not found: %v", err) + } + condition := assertCondition(t, gotZone.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") + if condition.Message != "1 RecordSet resources still reference this Zone; delete or move them before Zone cleanup can continue" { + t.Fatalf("Programmed message = %q", condition.Message) + } +} + +func TestZoneUnitCompositionReconcilerCreatesZoneUnitForDeletingZoneWithRecordSets(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) + zone.DeletionTimestamp = &deletionTime + zone.Finalizers = []string{coreZoneFinalizer} + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not created: %v", err) + } + if !gotUnit.DeletionTimestamp.IsZero() { + t.Fatalf("ZoneUnit deletionTimestamp = %v, want not deleting", gotUnit.DeletionTimestamp) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want one remaining item", gotUnit.Spec.RecordSets) + } +} + +func TestZoneUnitCompositionReconcilerRemovesZoneFinalizerWhenDeletingZoneHasNoZoneUnitOrRecordSets(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) + zone.DeletionTimestamp = &deletionTime + zone.Finalizers = []string{coreZoneFinalizer} + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotZone dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &gotZone); err != nil { + if apierrors.IsNotFound(err) { + return + } + t.Fatalf("Zone get returned error: %v", err) + } + if len(gotZone.Finalizers) != 0 { + t.Fatalf("Zone finalizers = %#v, want none", gotZone.Finalizers) + } +} + +func TestZoneUnitCompositionReconcilerKeepsZoneUnitActiveForDeletingRecordSetsWhileZoneDeletes(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) + zone.DeletionTimestamp = &deletionTime + zone.Finalizers = []string{coreZoneFinalizer} + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.DeletionTimestamp = &deletionTime + recordSet.Finalizers = []string{coreRecordSetFinalizer} + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "app", + Name: "apps-example-com", + Finalizers: []string{coreZoneUnitFinalizer, "route53.dns.appthrust.io/zoneunit-finalizer"}, + }, + Spec: zoneUnitSpecForZoneUnitTest(zone), + Status: dnsv1alpha1.ZoneUnitStatus{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: recordSet.UID, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + }, + }, + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(recordSet, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if !gotUnit.DeletionTimestamp.IsZero() { + t.Fatalf("ZoneUnit deletionTimestamp = %v, want not deleting", gotUnit.DeletionTimestamp) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want deleting item", gotUnit.Spec.RecordSets) + } + if !gotUnit.Spec.RecordSets[0].DeletionRequested { + t.Fatalf("recordSet item = %#v, want deletionRequested", gotUnit.Spec.RecordSets[0]) + } +} + +func TestZoneUnitCompositionReconcilerPrioritizesDeletingRecordSetOverNewConflictingRecordSet(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + deletingA := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + deletionTime := metav1.NewTime(time.Date(2026, 6, 10, 12, 10, 35, 0, time.UTC)) + deletingA.DeletionTimestamp = &deletionTime + deletingA.Finalizers = []string{coreRecordSetFinalizer} + cname := cnameRecordSet("app", "www-cname", "app", "apps-example-com", "www") + provider := route53ProviderForZoneUnitTest() + provider.Spec.Versions[0].RecordSet.SupportedTypes = append(provider.Spec.Versions[0].RecordSet.SupportedTypes, dnsv1alpha1.RecordTypeCNAME) + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: zoneUnitSpecForZoneUnitTest(zone), + Status: dnsv1alpha1.ZoneUnitStatus{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: deletingA.UID, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + }, + }, + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(cname, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(provider, route53ZoneClassForZoneUnitTest(), zone, deletingA, cname, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if len(gotUnit.Spec.RecordSets) != 1 { + t.Fatalf("recordSets = %#v, want only deleting A item", gotUnit.Spec.RecordSets) + } + item := gotUnit.Spec.RecordSets[0] + if item.RecordSetName != "www-a" || item.Type != dnsv1alpha1.RecordTypeA || !item.DeletionRequested { + t.Fatalf("recordSet item = %#v, want deleting A item", item) + } + + var gotCNAME dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cname), &gotCNAME); err != nil { + t.Fatalf("CNAME RecordSet was not found: %v", err) + } + accepted := assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") + if accepted.Message != "RecordSet type=CNAME name=www in Zone app/apps-example-com conflicts with owner RecordSet app/www-a." { + t.Fatalf("Accepted message = %q", accepted.Message) + } + programmed := assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") + if programmed.Message != recordSetNotAcceptedProgrammedMessage { + t.Fatalf("Programmed message = %q", programmed.Message) + } +} + +func TestZoneUnitCompositionReconcilerDoesNotProjectStaleDeletedRecordSetStatusToRecreatedRecordSet(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + recreatedA := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + cname := cnameRecordSet("app", "www-cname", "app", "apps-example-com", "www") + provider := route53ProviderForZoneUnitTest() + provider.Spec.Versions[0].RecordSet.SupportedTypes = append(provider.Spec.Versions[0].RecordSet.SupportedTypes, dnsv1alpha1.RecordTypeCNAME) + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: zoneUnitSpecForZoneUnitTest(zone), + Status: dnsv1alpha1.ZoneUnitStatus{ + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: types.UID("predecessor-recordset"), + ObservedGeneration: 2, + DeletionCompleted: true, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + { + RecordSetNamespace: "app", + RecordSetName: "www-cname", + RecordSetUID: cname.UID, + ObservedGeneration: cname.Generation, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + }, + }, + } + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + zoneUnitRecordSetItem(cname, providercontract.Payload{}), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(provider, route53ZoneClassForZoneUnitTest(), zone, recreatedA, cname, unit). + WithStatusSubresource(&dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotA dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recreatedA), &gotA); err != nil { + t.Fatalf("A RecordSet was not found: %v", err) + } + accepted := assertCondition(t, gotA.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") + if accepted.Message != "RecordSet type=A name=www in Zone app/apps-example-com conflicts with owner RecordSet app/www-cname." { + t.Fatalf("Accepted message = %q", accepted.Message) + } + programmed := assertCondition(t, gotA.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") + if programmed.Message != recordSetNotAcceptedProgrammedMessage { + t.Fatalf("Programmed message = %q", programmed.Message) + } + + var gotCNAME dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cname), &gotCNAME); err != nil { + t.Fatalf("CNAME RecordSet was not found: %v", err) + } + assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + assertCondition(t, gotCNAME.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") +} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_status.go b/internal/go/core/controllers/zoneunit/zoneunit_status.go new file mode 100644 index 0000000..f20e0e8 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/zoneunit_status.go @@ -0,0 +1,193 @@ +package zoneunit + +import ( + "context" + "slices" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *ZoneUnitCompositionReconciler) projectZoneUnitStatus(ctx context.Context, zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass, recordSets []dnsv1alpha1.RecordSet, unit *dnsv1alpha1.ZoneUnit) error { + if unit.Status.Zone != nil { + accepted := projectedAccepted(unit.Spec.Zone.ObservedGeneration, zone.Status.Conditions, unit.Status.Zone.Conditions) + programmed := projectedProgrammed(unit.Spec.Zone.ObservedGeneration, accepted.Status, unit.Status.Zone.Conditions) + if err := r.setZoneClaimStatus(ctx, zone, accepted.Status, accepted.Reason, programmed.Status, programmed.Reason, unit.Status.Zone); err != nil { + return err + } + } + statusByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetStatus{} + incompatibleStatusByRef := map[string]struct{}{} + desiredByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetSpec{} + for _, item := range unit.Spec.RecordSets { + desiredByRef[zoneUnitRecordSetItemKey(item)] = item + } + for _, status := range unit.Status.RecordSets { + refKey := zoneUnitRecordSetStatusKey(status) + desiredItem, ok := desiredByRef[refKey] + if !ok || retainedNotAllowedRecordSetItem(desiredItem) { + continue + } + if staleCompletedDeletionStatusForActiveItem(status, desiredItem) || !zoneUnitRecordSetStatusMatchesItem(status, desiredItem) { + incompatibleStatusByRef[refKey] = struct{}{} + continue + } + statusByRef[refKey] = status + } + for index := range recordSets { + recordSet := &recordSets[index] + refKey := recordSetClaimKey(recordSet.Namespace, recordSet.Name) + desiredItem, currentClaim := desiredByRef[refKey] + providerStatus, hasProviderStatus := statusByRef[refKey] + if !currentClaim || !zoneUnitRecordSetItemMatchesRecordSet(desiredItem, recordSet) { + continue + } + if !hasProviderStatus { + if _, incompatible := incompatibleStatusByRef[refKey]; !incompatible { + continue + } + if err := r.setRecordSetClaimStatusWithZone(ctx, recordSet, metav1.ConditionUnknown, "OwnerStateNotResolved", metav1.ConditionUnknown, "Reconciling", recordSetZoneStatus(zone, zoneClass), &dnsv1alpha1.ZoneUnitRecordSetStatus{}); err != nil { + return err + } + continue + } + var providerConditions []metav1.Condition + if providerStatus.ObservedGeneration == desiredItem.ObservedGeneration { + providerConditions = providerStatus.Conditions + } + accepted := projectedAccepted(desiredItem.ObservedGeneration, recordSet.Status.Conditions, providerConditions) + programmed := projectedProgrammed(desiredItem.ObservedGeneration, accepted.Status, providerConditions) + if err := r.setRecordSetClaimStatusWithZone(ctx, recordSet, accepted.Status, accepted.Reason, programmed.Status, programmed.Reason, recordSetZoneStatus(zone, zoneClass), &providerStatus); err != nil { + return err + } + } + return nil +} + +func staleCompletedDeletionStatusForActiveItem(status dnsv1alpha1.ZoneUnitRecordSetStatus, item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return status.DeletionCompleted && !item.DeletionRequested +} + +func retainedNotAllowedRecordSetItem(item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return !item.IsAllowed() && !item.DeletionRequested +} + +type projectedCondition struct { + Status metav1.ConditionStatus + Reason string +} + +func projectedAccepted(observedGeneration int64, compositionConditions, providerConditions []metav1.Condition) projectedCondition { + provider := conditionAtGeneration(providerConditions, string(dnsv1alpha1.ConditionAccepted), observedGeneration) + if provider != nil { + return projectedCondition{Status: provider.Status, Reason: provider.Reason} + } + composition := conditionAtGeneration(compositionConditions, string(dnsv1alpha1.ConditionAccepted), observedGeneration) + if composition != nil && composition.Status != metav1.ConditionTrue { + return projectedCondition{Status: composition.Status, Reason: composition.Reason} + } + return projectedCondition{Status: metav1.ConditionUnknown, Reason: "OwnerStateNotResolved"} +} + +func projectedProgrammed(observedGeneration int64, accepted metav1.ConditionStatus, providerConditions []metav1.Condition) projectedCondition { + if accepted != metav1.ConditionTrue { + return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling"} + } + provider := conditionAtGeneration(providerConditions, string(dnsv1alpha1.ConditionProgrammed), observedGeneration) + if provider == nil { + return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling"} + } + return projectedCondition{Status: provider.Status, Reason: provider.Reason} +} + +func conditionAtGeneration(conditions []metav1.Condition, conditionType string, observedGeneration int64) *metav1.Condition { + condition := meta.FindStatusCondition(conditions, conditionType) + if condition == nil || condition.ObservedGeneration != observedGeneration { + return nil + } + return condition +} + +func (r *ZoneUnitCompositionReconciler) writeRecordSetsWaiting(ctx context.Context, recordSets []dnsv1alpha1.RecordSet, reason string) error { + for index := range recordSets { + if err := r.setRecordSetClaimStatus(ctx, &recordSets[index], metav1.ConditionUnknown, reason, metav1.ConditionUnknown, "Reconciling", nil); err != nil { + return err + } + } + return nil +} + +func (r *ZoneUnitCompositionReconciler) writeRecordSetsNotAccepted(ctx context.Context, recordSets []dnsv1alpha1.RecordSet, reason string) error { + for index := range recordSets { + if err := r.setRecordSetClaimStatus(ctx, &recordSets[index], metav1.ConditionFalse, reason, metav1.ConditionUnknown, "Reconciling", nil); err != nil { + return err + } + } + return nil +} + +func (r *ZoneUnitCompositionReconciler) setZoneClaimStatus(ctx context.Context, zone *dnsv1alpha1.Zone, acceptedStatus metav1.ConditionStatus, acceptedReason string, programmedStatus metav1.ConditionStatus, programmedReason string, providerStatus *dnsv1alpha1.ZoneUnitZoneStatus) error { + return r.setZoneClaimStatusWithMessages(ctx, zone, acceptedStatus, acceptedReason, acceptedReason, programmedStatus, programmedReason, programmedReason, providerStatus) +} + +func (r *ZoneUnitCompositionReconciler) setZoneClaimStatusWithMessages(ctx context.Context, zone *dnsv1alpha1.Zone, acceptedStatus metav1.ConditionStatus, acceptedReason, acceptedMessage string, programmedStatus metav1.ConditionStatus, programmedReason, programmedMessage string, providerStatus *dnsv1alpha1.ZoneUnitZoneStatus) error { + base := zone.DeepCopy() + zone.Status.ObservedGeneration = zone.Generation + meta.SetStatusCondition(&zone.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionAccepted), Status: acceptedStatus, Reason: acceptedReason, Message: acceptedMessage, ObservedGeneration: zone.Generation}) + meta.SetStatusCondition(&zone.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionProgrammed), Status: programmedStatus, Reason: programmedReason, Message: programmedMessage, ObservedGeneration: zone.Generation}) + if providerStatus != nil { + zone.Status.NameServers = slices.Clone(providerStatus.NameServers) + if providerStatus.Provider != nil && len(providerStatus.Provider.Data.Raw) > 0 { + zone.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: providerStatus.Provider.Data} + } else { + zone.Status.Provider = nil + } + } + if equality.Semantic.DeepEqual(base.Status, zone.Status) { + return nil + } + return client.IgnoreNotFound(r.Status().Patch(ctx, zone, client.MergeFrom(base))) +} + +func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason string, programmedStatus metav1.ConditionStatus, programmedReason string, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { + return r.setRecordSetClaimStatusWithMessages(ctx, recordSet, acceptedStatus, acceptedReason, acceptedReason, programmedStatus, programmedReason, programmedReason, providerStatus) +} + +func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatusWithZone(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason string, programmedStatus metav1.ConditionStatus, programmedReason string, zoneStatus *dnsv1alpha1.RecordSetZoneStatus, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { + return r.setRecordSetClaimStatusWithZoneAndMessages(ctx, recordSet, acceptedStatus, acceptedReason, acceptedReason, programmedStatus, programmedReason, programmedReason, zoneStatus, providerStatus) +} + +func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatusWithMessages(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason, acceptedMessage string, programmedStatus metav1.ConditionStatus, programmedReason, programmedMessage string, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { + return r.setRecordSetClaimStatusWithZoneAndMessages(ctx, recordSet, acceptedStatus, acceptedReason, acceptedMessage, programmedStatus, programmedReason, programmedMessage, nil, providerStatus) +} + +func (r *ZoneUnitCompositionReconciler) setRecordSetClaimStatusWithZoneAndMessages(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, acceptedStatus metav1.ConditionStatus, acceptedReason, acceptedMessage string, programmedStatus metav1.ConditionStatus, programmedReason, programmedMessage string, zoneStatus *dnsv1alpha1.RecordSetZoneStatus, providerStatus *dnsv1alpha1.ZoneUnitRecordSetStatus) error { + base := recordSet.DeepCopy() + recordSet.Status.ObservedGeneration = recordSet.Generation + if zoneStatus != nil { + recordSet.Status.Zone = zoneStatus + } + meta.SetStatusCondition(&recordSet.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionAccepted), Status: acceptedStatus, Reason: acceptedReason, Message: acceptedMessage, ObservedGeneration: recordSet.Generation}) + meta.SetStatusCondition(&recordSet.Status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionProgrammed), Status: programmedStatus, Reason: programmedReason, Message: programmedMessage, ObservedGeneration: recordSet.Generation}) + if providerStatus != nil && providerStatus.Provider != nil && len(providerStatus.Provider.Data.Raw) > 0 { + recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: providerStatus.Provider.Data} + } else if providerStatus != nil { + recordSet.Status.Provider = nil + } + if equality.Semantic.DeepEqual(base.Status, recordSet.Status) { + return nil + } + return client.IgnoreNotFound(r.Status().Patch(ctx, recordSet, client.MergeFrom(base))) +} + +func recordSetZoneStatus(zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass) *dnsv1alpha1.RecordSetZoneStatus { + if zone == nil { + return nil + } + return &dnsv1alpha1.RecordSetZoneStatus{ + Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, + } +} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go b/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go new file mode 100644 index 0000000..3a152a3 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go @@ -0,0 +1,165 @@ +package zoneunit + +import ( + "context" + "testing" + + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + 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 TestZoneUnitCompositionReconcilerDoesNotRebadgeStaleSameUIDProviderStatus(t *testing.T) { + ctx := context.Background() + zone := zone("app", "apps-example-com", "apps.example.com") + zone.Spec.ZoneClassRef.Namespace = ptr("platform") + zone.Generation = 2 + recordSet := aRecordSet("app", "www-a", "app", "apps-example-com", "www") + recordSet.UID = types.UID("current-recordset") + recordSet.Generation = 2 + staleItem := zoneUnitRecordSetItem(recordSet, providercontract.Payload{}) + staleItem.ObservedGeneration = 1 + unit := &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com", Generation: 1}, + Spec: dnsv1alpha1.ZoneUnitSpec{ + Provider: dnsv1alpha1.ProviderReference{Name: "route53.dns.appthrust.io", Version: "v1alpha1"}, + Zone: dnsv1alpha1.ZoneUnitZoneSpec{ + Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, + ObservedGeneration: 1, + DomainName: zone.Spec.DomainName, + ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: "platform", Name: "route53-public"}, + }, + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{staleItem}, + }, + Status: dnsv1alpha1.ZoneUnitStatus{ + ObservedGeneration: 1, + Zone: &dnsv1alpha1.ZoneUnitZoneStatus{ + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + }, + }, + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: recordSet.UID, + ObservedGeneration: 1, + Provider: &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: []byte(`{"recordID":"current"}`)}}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + }, + }}, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53ProviderForZoneUnitTest(), route53ZoneClassForZoneUnitTest(), zone, recordSet, unit). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zone)} + + if _, err := reconciler.Reconcile(ctx, request); err != nil { + t.Fatalf("Reconcile with stale provider status returned error: %v", err) + } + + var staleProjection dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &staleProjection); err != nil { + t.Fatalf("get RecordSet after stale projection: %v", err) + } + assertConditionAtGeneration(t, staleProjection.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionUnknown, "OwnerStateNotResolved", recordSet.Generation) + assertConditionAtGeneration(t, staleProjection.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling", recordSet.Generation) + if staleProjection.Status.Provider == nil { + t.Fatal("same-UID stale provider status erased provider ownership") + } + assertRawEqual(t, staleProjection.Status.Provider.Data, map[string]any{"recordID": "current"}) + + var staleZoneProjection dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &staleZoneProjection); err != nil { + t.Fatalf("get Zone after stale projection: %v", err) + } + assertConditionAtGeneration(t, staleZoneProjection.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionUnknown, "OwnerStateNotResolved", zone.Generation) + assertConditionAtGeneration(t, staleZoneProjection.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling", zone.Generation) + + var currentUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), ¤tUnit); err != nil { + t.Fatalf("get ZoneUnit after desired generation update: %v", err) + } + if len(currentUnit.Spec.RecordSets) != 1 { + t.Fatalf("ZoneUnit recordSets = %#v, want one current item", currentUnit.Spec.RecordSets) + } + if item := currentUnit.Spec.RecordSets[0]; item.RecordSetUID != recordSet.UID || item.ObservedGeneration != recordSet.Generation { + t.Fatalf("ZoneUnit recordSet item = %#v, want UID %q at generation %d", item, recordSet.UID, recordSet.Generation) + } + currentUnit.Status.ObservedGeneration = currentUnit.Generation + currentUnit.Status.Zone.Conditions = []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: zone.Generation}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: zone.Generation}, + } + currentUnit.Status.RecordSets[0].ObservedGeneration = recordSet.Generation + currentUnit.Status.RecordSets[0].Conditions = []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: recordSet.Generation}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + } + if err := k8sClient.Status().Update(ctx, ¤tUnit); err != nil { + t.Fatalf("write accepted-only current provider status: %v", err) + } + + if _, err := reconciler.Reconcile(ctx, request); err != nil { + t.Fatalf("Reconcile with accepted-only current provider status returned error: %v", err) + } + + var acceptedOnlyProjection dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &acceptedOnlyProjection); err != nil { + t.Fatalf("get RecordSet after accepted-only provider status: %v", err) + } + assertConditionAtGeneration(t, acceptedOnlyProjection.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted", recordSet.Generation) + assertConditionAtGeneration(t, acceptedOnlyProjection.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling", recordSet.Generation) + + var recoveredZone dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), &recoveredZone); err != nil { + t.Fatalf("get Zone after current provider status: %v", err) + } + assertConditionAtGeneration(t, recoveredZone.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted", zone.Generation) + assertConditionAtGeneration(t, recoveredZone.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed", zone.Generation) + + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), ¤tUnit); err != nil { + t.Fatalf("get ZoneUnit before programmed recovery: %v", err) + } + currentUnit.Status.RecordSets[0].Conditions = []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: recordSet.Generation}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: recordSet.Generation}, + } + if err := k8sClient.Status().Update(ctx, ¤tUnit); err != nil { + t.Fatalf("write fully current provider status: %v", err) + } + + if _, err := reconciler.Reconcile(ctx, request); err != nil { + t.Fatalf("Reconcile with fully current provider status returned error: %v", err) + } + + var recovered dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &recovered); err != nil { + t.Fatalf("get RecordSet after provider recovery: %v", err) + } + assertConditionAtGeneration(t, recovered.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted", recordSet.Generation) + assertConditionAtGeneration(t, recovered.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed", recordSet.Generation) + if recovered.Status.Provider == nil { + t.Fatal("provider ownership did not survive fresh recovery") + } + assertRawEqual(t, recovered.Status.Provider.Data, map[string]any{"recordID": "current"}) +} + +func assertConditionAtGeneration(t *testing.T, conditions []metav1.Condition, conditionType string, status metav1.ConditionStatus, reason string, generation int64) { + t.Helper() + condition := assertCondition(t, conditions, conditionType, status, reason) + if condition.ObservedGeneration != generation { + t.Fatalf("condition %s observedGeneration = %d, want %d", conditionType, condition.ObservedGeneration, generation) + } +} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_watches.go b/internal/go/core/controllers/zoneunit/zoneunit_watches.go new file mode 100644 index 0000000..92365e2 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/zoneunit_watches.go @@ -0,0 +1,151 @@ +package zoneunit + +import ( + "context" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func (r *ZoneUnitCompositionReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("zoneunit-composition"). + For(&dnsv1alpha1.Zone{}). + Watches(&dnsv1alpha1.RecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapRecordSetToZone)). + Watches(&dnsv1alpha1.ZoneUnit{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneUnitToZone)). + Watches(&dnsv1alpha1.ZoneClass{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneClassToZones)). + Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapProviderToZones)). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.mapNamespaceToZones)). + Complete(r) +} + +func (r *ZoneUnitCompositionReconciler) mapRecordSetToZone(_ context.Context, obj client.Object) []reconcile.Request { + recordSet, ok := obj.(*dnsv1alpha1.RecordSet) + if !ok { + return nil + } + namespace, name := recordSetZoneKey(recordSet) + return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: namespace, Name: name}}} +} + +func (r *ZoneUnitCompositionReconciler) mapZoneUnitToZone(_ context.Context, obj client.Object) []reconcile.Request { + unit, ok := obj.(*dnsv1alpha1.ZoneUnit) + if !ok { + return nil + } + return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: unit.Spec.Zone.Ref.Namespace, Name: unit.Spec.Zone.Ref.Name}}} +} + +func (r *ZoneUnitCompositionReconciler) mapZoneClassToZones(ctx context.Context, obj client.Object) []reconcile.Request { + zoneClass, ok := obj.(*dnsv1alpha1.ZoneClass) + if !ok { + return nil + } + var zones dnsv1alpha1.ZoneList + if err := r.List(ctx, &zones); err != nil { + return nil + } + requests := make([]reconcile.Request, 0) + for _, zone := range zones.Items { + namespace := zone.Namespace + if zone.Spec.ZoneClassRef.Namespace != nil && *zone.Spec.ZoneClassRef.Namespace != "" { + namespace = *zone.Spec.ZoneClassRef.Namespace + } + if namespace == zoneClass.Namespace && zone.Spec.ZoneClassRef.Name == zoneClass.Name { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&zone)}) + } + } + return requests +} + +func (r *ZoneUnitCompositionReconciler) mapProviderToZones(ctx context.Context, obj client.Object) []reconcile.Request { + provider, ok := obj.(*dnsv1alpha1.Provider) + if !ok { + return nil + } + var zones dnsv1alpha1.ZoneList + if err := r.List(ctx, &zones); err != nil { + return nil + } + requests := make([]reconcile.Request, 0) + for _, zone := range zones.Items { + if zone.Spec.Provider.Name == provider.Name { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&zone)}) + } + } + return requests +} + +func (r *ZoneUnitCompositionReconciler) mapNamespaceToZones(ctx context.Context, obj client.Object) []reconcile.Request { + namespace, ok := obj.(*corev1.Namespace) + if !ok { + return nil + } + seen := map[client.ObjectKey]struct{}{} + var requests []reconcile.Request + appendRequest := func(key client.ObjectKey) { + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: key}) + } + + var zones dnsv1alpha1.ZoneList + if err := r.List(ctx, &zones); err != nil { + return nil + } + for _, zone := range zones.Items { + if zone.Namespace == namespace.Name { + appendRequest(client.ObjectKeyFromObject(&zone)) + } + } + + var recordSets dnsv1alpha1.RecordSetList + if err := r.List(ctx, &recordSets); err != nil { + return requests + } + for _, recordSet := range recordSets.Items { + if recordSet.Namespace != namespace.Name { + continue + } + zoneNamespace, zoneName := recordSetZoneKey(&recordSet) + appendRequest(client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}) + } + return requests +} + +func recordSetClaimKey(namespace, name string) string { + return namespace + "\x00" + name +} + +func recordSetZoneKey(recordSet *dnsv1alpha1.RecordSet) (string, string) { + namespace := recordSet.Namespace + if recordSet.Spec.ZoneRef.Namespace != nil && *recordSet.Spec.ZoneRef.Namespace != "" { + namespace = *recordSet.Spec.ZoneRef.Namespace + } + return namespace, recordSet.Spec.ZoneRef.Name +} + +func zoneUnitRecordSetItemKey(item dnsv1alpha1.ZoneUnitRecordSetSpec) string { + return recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) +} + +func zoneUnitRecordSetStatusKey(item dnsv1alpha1.ZoneUnitRecordSetStatus) string { + return recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) +} + +func compareString(a, b string) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go index 1f6664f..3991f07 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go @@ -2,12 +2,9 @@ package cloudflare import ( "context" - "encoding/json" "errors" "fmt" - "regexp" "slices" - "strings" "time" cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1" @@ -29,8 +26,6 @@ const ( cloudflareFixedTTLMax = int32(86400) ) -var cloudflareTagPattern = regexp.MustCompile(`^([A-Za-z0-9_-]{1,32}):(.*)$`) - type CloudflareDNSRecord struct { ID string Type string @@ -88,8 +83,13 @@ func (r *recordSetReconciler) reconcileZoneUnitRecordSets(ctx context.Context, u if !item.IsAllowed() && !item.DeletionRequested { continue } + if item.RecordSetUID == "" { + aggregate = mergeCloudflareRecordSetResult(aggregate, ctrl.Result{RequeueAfter: r.requeueAfter()}) + continue + } recordSet := cloudflareRecordSetFromZoneUnitItem(unit, item, statusByRef[cloudflareRecordSetClaimKey(item.RecordSetNamespace, item.RecordSetName)]) ctxData, accepted, err := r.acceptRecordSetFromZoneUnit(ctx, &recordSet, unit) + ctxData.RecordSetSource = item.DeepCopy() if err != nil || !accepted { return aggregate, err } @@ -123,14 +123,15 @@ func (r *recordSetReconciler) reconcileZoneUnitRecordSets(ctx context.Context, u } type cloudflareRecordSetContext struct { - Zone *dnsv1alpha1.Zone - ZoneClass *dnsv1alpha1.ZoneClass - Identity *cloudflarev1alpha1.CloudflareIdentity - Provider *dnsv1alpha1.Provider - Version *dnsv1alpha1.ProviderVersion - ZoneUnit *dnsv1alpha1.ZoneUnit - ZoneID string - FullName string + Zone *dnsv1alpha1.Zone + ZoneClass *dnsv1alpha1.ZoneClass + Identity *cloudflarev1alpha1.CloudflareIdentity + Provider *dnsv1alpha1.Provider + Version *dnsv1alpha1.ProviderVersion + ZoneUnit *dnsv1alpha1.ZoneUnit + RecordSetSource *dnsv1alpha1.ZoneUnitRecordSetSpec + ZoneID string + FullName string } func cloudflareRecordSetFromZoneUnitItem(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitRecordSetSpec, status dnsv1alpha1.ZoneUnitRecordSetStatus) dnsv1alpha1.RecordSet { @@ -138,6 +139,7 @@ func cloudflareRecordSetFromZoneUnitItem(unit *dnsv1alpha1.ZoneUnit, item dnsv1a ObjectMeta: metav1.ObjectMeta{ Namespace: item.RecordSetNamespace, Name: item.RecordSetName, + UID: item.RecordSetUID, Generation: item.ObservedGeneration, }, Spec: dnsv1alpha1.RecordSetSpec{ @@ -156,13 +158,10 @@ func cloudflareRecordSetFromZoneUnitItem(unit *dnsv1alpha1.ZoneUnit, item dnsv1a Options: item.Options, Adoption: item.Adoption, }, - Status: dnsv1alpha1.RecordSetStatus{ - ObservedGeneration: status.ObservedGeneration, - Provider: status.Provider, - Conditions: slices.Clone(status.Conditions), - }, } - if status.ObservedGeneration == 0 { + if cloudflareZoneUnitRecordSetStatusMatchesItem(status, item) { + recordSet.Status = cloudflareRecordSetStatusFromZoneUnitStatus(status) + } else { recordSet.Status.ObservedGeneration = item.ObservedGeneration } if item.DeletionRequested { @@ -172,6 +171,77 @@ func cloudflareRecordSetFromZoneUnitItem(unit *dnsv1alpha1.ZoneUnit, item dnsv1a return recordSet } +func cloudflareRecordSetStatusFromZoneUnitStatus(status dnsv1alpha1.ZoneUnitRecordSetStatus) dnsv1alpha1.RecordSetStatus { + return dnsv1alpha1.RecordSetStatus{ + ObservedGeneration: status.ObservedGeneration, + Provider: status.Provider, + Conditions: slices.Clone(status.Conditions), + } +} + +func cloudflareZoneUnitRecordSetStatusMatchesItem(status dnsv1alpha1.ZoneUnitRecordSetStatus, item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + // Ownership belongs to the incarnation, not one desired generation. An + // ordinary update must retain provider IDs; readiness checks freshness below. + return status.RecordSetNamespace == item.RecordSetNamespace && + status.RecordSetName == item.RecordSetName && + status.RecordSetUID != "" && + status.RecordSetUID == item.RecordSetUID +} + +func cloudflareZoneUnitRecordSetSourceMatches(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitRecordSetSpec, recordSet *dnsv1alpha1.RecordSet) bool { + zoneNamespace, zoneName := recordSetZoneKey(recordSet) + return item.RecordSetNamespace == recordSet.Namespace && + item.RecordSetName == recordSet.Name && + item.RecordSetUID != "" && + item.RecordSetUID == recordSet.UID && + item.ObservedGeneration == recordSet.Generation && + item.DeletionRequested == !recordSet.DeletionTimestamp.IsZero() && + unit.Spec.Provider == recordSet.Spec.Provider && + zoneNamespace == unit.Namespace && + zoneName == unit.Name && + item.Name == recordSet.Spec.Name && + item.Type == recordSet.Spec.Type && + equality.Semantic.DeepEqual(item.TTL, recordSet.Spec.TTL) && + equality.Semantic.DeepEqual(item.A, recordSet.Spec.A) && + equality.Semantic.DeepEqual(item.AAAA, recordSet.Spec.AAAA) && + equality.Semantic.DeepEqual(item.TXT, recordSet.Spec.TXT) && + equality.Semantic.DeepEqual(item.CNAME, recordSet.Spec.CNAME) && + equality.Semantic.DeepEqual(item.MX, recordSet.Spec.MX) && + equality.Semantic.DeepEqual(item.CAA, recordSet.Spec.CAA) && + equality.Semantic.DeepEqual(item.NS, recordSet.Spec.NS) && + equality.Semantic.DeepEqual(item.Options, recordSet.Spec.Options) && + equality.Semantic.DeepEqual(item.Adoption, recordSet.Spec.Adoption) +} + +// authorizeCloudflareRecordSetMutation makes the status subresource the +// API-server-authoritative mutation fence. The source check rejects a current +// cache observation that no longer represents this claim; the optimistic, +// otherwise no-op patch rejects an observation superseded in the API server. +// It is intentionally called only immediately before an external write. +func (r *recordSetReconciler) authorizeCloudflareRecordSetMutation(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, source *dnsv1alpha1.ZoneUnitRecordSetSpec) error { + zoneNamespace, zoneName := recordSetZoneKey(recordSet) + var unit dnsv1alpha1.ZoneUnit + if err := r.Get(ctx, client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}, &unit); err != nil { + if apierrors.IsNotFound(err) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), zoneName, fmt.Errorf("ZoneUnit was deleted before Cloudflare DNS records could be changed")) + } + return err + } + itemIndex := -1 + if source != nil { + itemIndex = slices.IndexFunc(unit.Spec.RecordSets, func(item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return item.RecordSetNamespace == source.RecordSetNamespace && item.RecordSetName == source.RecordSetName + }) + } + if itemIndex < 0 || + !cloudflareZoneUnitRecordSetSourceMatches(&unit, unit.Spec.RecordSets[itemIndex], recordSet) || + !equality.Semantic.DeepEqual(unit.Spec.RecordSets[itemIndex], *source) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), zoneName, fmt.Errorf("ZoneUnit RecordSet source changed before Cloudflare DNS records could be changed")) + } + unitBase := unit.DeepCopy() + return r.Status().Patch(ctx, &unit, client.MergeFromWithOptions(unitBase, client.MergeFromWithOptimisticLock{})) +} + func mergeCloudflareRecordSetResult(left, right ctrl.Result) ctrl.Result { if right.Requeue { left.Requeue = true @@ -247,12 +317,13 @@ func (r *recordSetReconciler) acceptRecordSetFromZoneUnit(ctx context.Context, r ZoneID: zoneStatus.Zone.ID, FullName: cloudflareFullRecordName(recordSet.Spec.Name, zone.Spec.DomainName), } - if err := r.patchStatus(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { + if err := r.patchStatus(ctx, recordSet, false, func(status *dnsv1alpha1.RecordSetStatus) error { status.ObservedGeneration = recordSet.Generation status.Zone = &dnsv1alpha1.RecordSetZoneStatus{ Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, } setCondition(&status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted", "RecordSet is accepted by Cloudflare policy", recordSet.Generation) + return nil }); err != nil { return out, false, err } @@ -303,12 +374,12 @@ func (r *recordSetReconciler) reconcileNormal(ctx context.Context, provider Reco return r.applyCloudflareRecordSetDiff(ctx, provider, recordSet, ctxData, nil, desired) } managed, _ := cloudflareRecordsByIDs(sameType, managedIDs) - if unmanaged := cloudflareRecordsExcludingIDs(sameType, managedIDs); len(unmanaged) > 0 { - observed := append(slices.Clone(managed), unmanaged...) - if cloudflareDNSRecordSetEqual(observed, desired) { - return ctrl.Result{}, r.setReady(ctx, recordSet, observed) + // Only IDs durably recorded for this UID are mutable. Desired equality + // does not establish that an external ID was created or adopted by it. + for _, record := range sameType { + if _, owned := managedIDs[record.ID]; !owned { + return ctrl.Result{}, r.setProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderConflict", "same Cloudflare DNS records already exist") } - return ctrl.Result{}, r.setProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderConflict", "same Cloudflare DNS records already exist") } return r.applyCloudflareRecordSetDiff(ctx, provider, recordSet, ctxData, managed, desired) } @@ -369,6 +440,13 @@ func (r *recordSetReconciler) reconcileDeleteWithContext(ctx context.Context, re } } if len(ids) == 0 { + observed, err := provider.ListDNSRecords(ctx, ctxData.ZoneID, ctxData.FullName) + if err != nil { + return r.failProgrammedForProviderError(ctx, recordSet, err) + } + if len(observed) > 0 { + return ctrl.Result{}, r.setProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderOwnershipNotEstablished", "Cloudflare DNS records exist but are not owned by the current RecordSet UID") + } return ctrl.Result{}, r.removeFinalizer(ctx, recordSet) } var deletes []CloudflareDNSRecord @@ -392,6 +470,9 @@ func (r *recordSetReconciler) reconcileDeleteWithContext(ctx context.Context, re deletes = append(deletes, observed) } if len(deletes) > 0 { + if err := r.authorizeCloudflareRecordSetMutation(ctx, recordSet, ctxData.RecordSetSource); err != nil { + return ctrl.Result{}, err + } if _, err := provider.BatchDNSRecords(ctx, ctxData.ZoneID, CloudflareDNSRecordBatch{Deletes: deletes}); err != nil && !isCloudflareNotFound(err) { return r.failProgrammedForProviderError(ctx, recordSet, err) } @@ -421,6 +502,9 @@ func (r *recordSetReconciler) applyCloudflareRecordSetDiff(ctx context.Context, return ctrl.Result{}, r.setReady(ctx, recordSet, current) } batch := cloudflareDNSRecordBatchFromOperations(operations) + if err := r.authorizeCloudflareRecordSetMutation(ctx, recordSet, ctxData.RecordSetSource); err != nil { + return ctrl.Result{}, err + } applied, err := provider.BatchDNSRecords(ctx, ctxData.ZoneID, batch) if err != nil { return r.failProgrammedForProviderError(ctx, recordSet, err) @@ -534,164 +618,6 @@ func (r *recordSetReconciler) identityForZoneClass(ctx context.Context, recordSe return &identity, true, nil } -func (r *recordSetReconciler) setReady(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, observed []CloudflareDNSRecord) error { - if err := validateCloudflareDNSRecordIDs(observed, "Cloudflare DNS record id"); err != nil { - return r.setProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderInvalidRequest", err.Error()) - } - if err := r.patchCloudflareRecordSetStatus(ctx, recordSet, func(data *cloudflarev1alpha1.CloudflareRecordSetStatusData, state *cloudflarev1alpha1.CloudflareRecordSetState) { - data.Records = cloudflareDNSRecordStatuses(observed) - state.Records = cloudflareDNSRecordStates(observed) - }); err != nil { - return err - } - if err := r.setAccepted(ctx, recordSet, metav1.ConditionTrue, "Accepted", "RecordSet is accepted by Cloudflare policy"); err != nil { - return err - } - return r.setProgrammed(ctx, recordSet, metav1.ConditionTrue, "Programmed", "Cloudflare DNS records match desired state") -} - -func (r *recordSetReconciler) setAccepted(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, status metav1.ConditionStatus, reason, message string) error { - return r.patchStatus(ctx, recordSet, func(recordSetStatus *dnsv1alpha1.RecordSetStatus) { - recordSetStatus.ObservedGeneration = recordSet.Generation - setCondition(&recordSetStatus.Conditions, string(dnsv1alpha1.ConditionAccepted), status, reason, message, recordSet.Generation) - }) -} - -func (r *recordSetReconciler) setProgrammed(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, status metav1.ConditionStatus, reason, message string) error { - return r.patchStatus(ctx, recordSet, func(recordSetStatus *dnsv1alpha1.RecordSetStatus) { - recordSetStatus.ObservedGeneration = recordSet.Generation - setCondition(&recordSetStatus.Conditions, string(dnsv1alpha1.ConditionProgrammed), status, reason, message, recordSet.Generation) - }) -} - -func (r *recordSetReconciler) patchStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*dnsv1alpha1.RecordSetStatus)) error { - base := recordSet.DeepCopy() - mutate(&recordSet.Status) - if equality.Semantic.DeepEqual(base.Status, recordSet.Status) { - return nil - } - - zoneNamespace, zoneName := recordSetZoneKey(recordSet) - var unit dnsv1alpha1.ZoneUnit - if err := r.Get(ctx, client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}, &unit); err != nil { - return client.IgnoreNotFound(err) - } - unitBase := unit.DeepCopy() - index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) - next := dnsv1alpha1.ZoneUnitRecordSetStatus{ - RecordSetNamespace: recordSet.Namespace, - RecordSetName: recordSet.Name, - ObservedGeneration: recordSet.Status.ObservedGeneration, - Provider: recordSet.Status.Provider, - Conditions: slices.Clone(recordSet.Status.Conditions), - } - if recordSet.DeletionTimestamp != nil { - programmed := meta.FindStatusCondition(recordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed)) - next.DeletionCompleted = programmed != nil && programmed.Status == metav1.ConditionTrue - } - if index >= 0 { - unit.Status.RecordSets[index] = next - } else { - unit.Status.RecordSets = append(unit.Status.RecordSets, next) - } - unit.Status.ObservedGeneration = unit.Generation - setZoneUnitProgrammedCondition(&unit) - if equality.Semantic.DeepEqual(unitBase.Status, unit.Status) { - return nil - } - return client.IgnoreNotFound(r.Status().Patch(ctx, &unit, client.MergeFrom(unitBase))) -} - -func setZoneUnitProgrammedCondition(unit *dnsv1alpha1.ZoneUnit) { - status, reason, message := zoneUnitProgrammedCondition(unit) - setCondition(&unit.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), status, reason, message, unit.Generation) -} - -func zoneUnitProgrammedCondition(unit *dnsv1alpha1.ZoneUnit) (metav1.ConditionStatus, string, string) { - if unit.Status.Zone == nil { - return metav1.ConditionUnknown, "Reconciling", "ZoneUnit zone status is not observed" - } - status := metav1.ConditionTrue - reason := "Programmed" - message := "ZoneUnit is programmed" - if condition := meta.FindStatusCondition(unit.Status.Zone.Conditions, string(dnsv1alpha1.ConditionProgrammed)); condition == nil { - status = metav1.ConditionUnknown - reason = "Reconciling" - message = "ZoneUnit zone Programmed condition is not observed" - } else if condition.Status == metav1.ConditionFalse { - return condition.Status, condition.Reason, condition.Message - } else if condition.Status == metav1.ConditionUnknown { - status = metav1.ConditionUnknown - reason = condition.Reason - message = condition.Message - } - for _, item := range unit.Spec.RecordSets { - if !item.IsAllowed() && !item.DeletionRequested { - continue - } - condition := zoneUnitRecordSetProgrammedCondition(unit, item) - if condition == nil { - if status != metav1.ConditionFalse { - status = metav1.ConditionUnknown - reason = "Reconciling" - message = "ZoneUnit record set Programmed condition is not observed" - } - continue - } - if condition.Status == metav1.ConditionFalse { - return condition.Status, condition.Reason, condition.Message - } - if condition.Status == metav1.ConditionUnknown && status != metav1.ConditionFalse { - status = metav1.ConditionUnknown - reason = condition.Reason - message = condition.Message - } - } - return status, reason, message -} - -func zoneUnitRecordSetProgrammedCondition(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitRecordSetSpec) *metav1.Condition { - index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == item.RecordSetNamespace && status.RecordSetName == item.RecordSetName - }) - if index < 0 { - return nil - } - return meta.FindStatusCondition(unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionProgrammed)) -} - -func (r *recordSetReconciler) patchCloudflareRecordSetStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*cloudflarev1alpha1.CloudflareRecordSetStatusData, *cloudflarev1alpha1.CloudflareRecordSetState)) error { - statusData, err := cloudflareRecordSetStatusData(recordSet) - if err != nil { - return err - } - statusState, err := cloudflareRecordSetState(recordSet) - if err != nil { - return err - } - mutate(&statusData, &statusState) - rawData, err := json.Marshal(statusData) - if err != nil { - return err - } - var rawState []byte - if len(statusState.Records) > 0 { - rawState, err = json.Marshal(statusState) - if err != nil { - return err - } - } - return r.patchStatus(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { - status.ObservedGeneration = recordSet.Generation - status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: rawData}} - if len(rawState) > 0 { - status.Provider.State = runtime.RawExtension{Raw: rawState} - } - }) -} - func (r *recordSetReconciler) upsertRecordStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, record CloudflareDNSRecord) error { return r.patchCloudflareRecordSetStatus(ctx, recordSet, func(data *cloudflarev1alpha1.CloudflareRecordSetStatusData, state *cloudflarev1alpha1.CloudflareRecordSetState) { status := cloudflareDNSRecordStatus(record) @@ -734,9 +660,10 @@ func (r *recordSetReconciler) ensureFinalizer(ctx context.Context, recordSet *dn } func (r *recordSetReconciler) removeFinalizer(ctx context.Context, recordSet *dnsv1alpha1.RecordSet) error { - return r.patchStatus(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { + return r.patchStatus(ctx, recordSet, true, func(status *dnsv1alpha1.RecordSetStatus) error { status.ObservedGeneration = recordSet.Generation setCondition(&status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed", "Cloudflare DNS record deletion is complete", recordSet.Generation) + return nil }) } @@ -808,468 +735,3 @@ func (r *recordSetReconciler) controllerName() string { func (r *recordSetReconciler) providerReference() dnsv1alpha1.ProviderReference { return cloudflareProviderReference(r.ProviderName, r.ProviderVersion) } - -func cloudflareRecordSetOptions(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetOptions, error) { - if len(recordSet.Spec.Options.Raw) == 0 { - return cloudflarev1alpha1.CloudflareRecordSetOptions{}, nil - } - var options cloudflarev1alpha1.CloudflareRecordSetOptions - if err := json.Unmarshal(recordSet.Spec.Options.Raw, &options); err != nil { - return cloudflarev1alpha1.CloudflareRecordSetOptions{}, fmt.Errorf("options must match Cloudflare RecordSet schema: %w", err) - } - return options, nil -} - -func validateCloudflareRecordSetOptions(recordSet *dnsv1alpha1.RecordSet, options cloudflarev1alpha1.CloudflareRecordSetOptions) string { - if options.TTL != "" && options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto { - return "options.ttl must be Auto" - } - if options.TTL != "" && recordSet.Spec.TTL != nil { - return "ttl must not be specified when cloudflare automatic ttl is used" - } - if options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto { - if recordSet.Spec.TTL == nil { - return "ttl is required for Cloudflare records unless options.ttl is Auto" - } - if !cloudflareFixedTTLAllowed(*recordSet.Spec.TTL) { - return "cloudflare fixed ttl must be between 60 and 86400 seconds" - } - } - if options.Proxied != nil && *options.Proxied { - if recordSet.Spec.Type != dnsv1alpha1.RecordTypeA && recordSet.Spec.Type != dnsv1alpha1.RecordTypeAAAA && recordSet.Spec.Type != dnsv1alpha1.RecordTypeCNAME { - return "cloudflare proxied is supported only for A, AAAA, and CNAME records" - } - if options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto || recordSet.Spec.TTL != nil { - return "cloudflare proxied records must use automatic ttl" - } - } - for _, tag := range options.Tags { - matches := cloudflareTagPattern.FindStringSubmatch(tag) - if matches == nil || strings.ContainsAny(matches[2], "\r\n") || len(matches[2]) > 100 { - return "cloudflare tags must use name:value format" - } - if strings.HasPrefix(strings.ToLower(matches[1]), "cf-") { - return "cloudflare tag names starting with cf- are reserved" - } - } - seenTags := map[string]struct{}{} - for _, tag := range options.Tags { - matches := cloudflareTagPattern.FindStringSubmatch(tag) - if matches == nil { - continue - } - key := strings.ToLower(matches[1]) + ":" + matches[2] - if _, ok := seenTags[key]; ok { - return "cloudflare tags must not contain duplicate name:value pairs" - } - seenTags[key] = struct{}{} - } - return "" -} - -func desiredCloudflareDNSRecords(recordSet *dnsv1alpha1.RecordSet, fullName string, options cloudflarev1alpha1.CloudflareRecordSetOptions) ([]CloudflareDNSRecord, error) { - ttl := int32(1) - if options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto { - if recordSet.Spec.TTL == nil { - return nil, errors.New("ttl is required for Cloudflare records unless options.ttl is Auto") - } - if !cloudflareFixedTTLAllowed(*recordSet.Spec.TTL) { - return nil, errors.New("cloudflare fixed ttl must be between 60 and 86400 seconds") - } - ttl = *recordSet.Spec.TTL - } - base := CloudflareDNSRecord{ - Type: string(recordSet.Spec.Type), - Name: fullName, - TTL: &ttl, - Comment: options.Comment, - Tags: slices.Clone(options.Tags), - } - if recordSet.Spec.Type == dnsv1alpha1.RecordTypeA || recordSet.Spec.Type == dnsv1alpha1.RecordTypeAAAA || recordSet.Spec.Type == dnsv1alpha1.RecordTypeCNAME { - proxied := false - if options.Proxied != nil { - proxied = *options.Proxied - } - base.Proxied = &proxied - } - var records []CloudflareDNSRecord - switch recordSet.Spec.Type { - case dnsv1alpha1.RecordTypeA: - for _, address := range recordSet.Spec.A.Addresses { - record := base - record.Content = address - records = append(records, record) - } - case dnsv1alpha1.RecordTypeAAAA: - for _, address := range recordSet.Spec.AAAA.Addresses { - record := base - record.Content = address - records = append(records, record) - } - case dnsv1alpha1.RecordTypeTXT: - for _, value := range recordSet.Spec.TXT.Values { - record := base - record.Content = value - records = append(records, record) - } - case dnsv1alpha1.RecordTypeCNAME: - record := base - record.Content = recordSet.Spec.CNAME.Target - records = append(records, record) - case dnsv1alpha1.RecordTypeMX: - for _, item := range recordSet.Spec.MX.Records { - record := base - record.Content = item.Exchange - record.Priority = &item.Preference - records = append(records, record) - } - case dnsv1alpha1.RecordTypeCAA: - for _, item := range recordSet.Spec.CAA.Records { - record := base - record.Content = item.Value - record.CAA = &CloudflareCAAData{Flags: item.Flags, Tag: item.Tag, Value: item.Value} - records = append(records, record) - } - case dnsv1alpha1.RecordTypeNS: - for _, nameServer := range recordSet.Spec.NS.NameServers { - record := base - record.Content = nameServer - records = append(records, record) - } - default: - return nil, errors.New("Cloudflare supports A, AAAA, TXT, CNAME, MX, CAA, and delegated NS records") - } - slices.SortFunc(records, compareCloudflareDNSRecord) - return records, nil -} - -func cloudflareFixedTTLAllowed(ttl int32) bool { - return ttl >= cloudflareFixedTTLMin && ttl <= cloudflareFixedTTLMax -} - -func cloudflareDNSRecordSetEqual(a, b []CloudflareDNSRecord) bool { - left := slices.Clone(a) - right := slices.Clone(b) - slices.SortFunc(left, compareCloudflareDNSRecord) - slices.SortFunc(right, compareCloudflareDNSRecord) - if len(left) != len(right) { - return false - } - for index := range left { - if !cloudflareDNSRecordEqual(left[index], right[index]) { - return false - } - } - return true -} - -func cloudflareDNSRecordEqual(a, b CloudflareDNSRecord) bool { - return a.Type == b.Type && - normalizeCloudflareName(a.Name) == normalizeCloudflareName(b.Name) && - (a.Type == string(dnsv1alpha1.RecordTypeCAA) || a.Content == b.Content) && - int32PtrValue(a.Priority) == int32PtrValue(b.Priority) && - int32PtrValue(a.TTL) == int32PtrValue(b.TTL) && - boolPtrValue(a.Proxied) == boolPtrValue(b.Proxied) && - a.Comment == b.Comment && - slices.Equal(cloudflareSortedTags(a.Tags), cloudflareSortedTags(b.Tags)) && - cloudflareCAAEqual(a.CAA, b.CAA) -} - -func compareCloudflareDNSRecord(a, b CloudflareDNSRecord) int { - for _, pair := range [][2]string{ - {a.Type, b.Type}, - {normalizeCloudflareName(a.Name), normalizeCloudflareName(b.Name)}, - {a.Content, b.Content}, - {fmt.Sprintf("%05d", int32PtrValue(a.Priority)), fmt.Sprintf("%05d", int32PtrValue(b.Priority))}, - {cloudflareCAAKey(a.CAA), cloudflareCAAKey(b.CAA)}, - } { - if pair[0] < pair[1] { - return -1 - } - if pair[0] > pair[1] { - return 1 - } - } - return 0 -} - -func cloudflareCAAEqual(a, b *CloudflareCAAData) bool { - return cloudflareCAAKey(a) == cloudflareCAAKey(b) -} - -func cloudflareCAAKey(value *CloudflareCAAData) string { - if value == nil { - return "" - } - return fmt.Sprintf("%d\x00%s\x00%s", value.Flags, value.Tag, value.Value) -} - -func cloudflareSortedTags(tags []string) []string { - out := slices.Clone(tags) - slices.Sort(out) - return out -} - -func cloudflareSameNameConflict(recordType dnsv1alpha1.RecordType, records []CloudflareDNSRecord) string { - for _, record := range records { - currentType := dnsv1alpha1.RecordType(record.Type) - if currentType == recordType { - continue - } - if recordType == dnsv1alpha1.RecordTypeCNAME || currentType == dnsv1alpha1.RecordTypeCNAME { - return "Cloudflare DNS record conflicts with same-name CNAME exclusivity" - } - if recordType == dnsv1alpha1.RecordTypeNS || currentType == dnsv1alpha1.RecordTypeNS { - return "Cloudflare DNS record conflicts with same-name delegated NS exclusivity" - } - } - return "" -} - -func cloudflareRecordsByType(records []CloudflareDNSRecord, recordType string) []CloudflareDNSRecord { - var out []CloudflareDNSRecord - for _, record := range records { - if record.Type == recordType { - out = append(out, record) - } - } - return out -} - -func cloudflareRecordsByIDs(records []CloudflareDNSRecord, ids map[string]struct{}) ([]CloudflareDNSRecord, []string) { - var out []CloudflareDNSRecord - found := make(map[string]struct{}, len(ids)) - for _, record := range records { - if _, ok := ids[record.ID]; ok { - out = append(out, record) - found[record.ID] = struct{}{} - } - } - var missing []string - for id := range ids { - if _, ok := found[id]; !ok { - missing = append(missing, id) - } - } - return out, missing -} - -func cloudflareRecordsExcludingIDs(records []CloudflareDNSRecord, ids map[string]struct{}) []CloudflareDNSRecord { - var out []CloudflareDNSRecord - for _, record := range records { - if _, ok := ids[record.ID]; !ok { - out = append(out, record) - } - } - return out -} - -func cloudflareRecordsHaveIDs(records []CloudflareDNSRecord, ids map[string]struct{}) bool { - if len(records) != len(ids) { - return false - } - for _, record := range records { - if _, ok := ids[record.ID]; !ok { - return false - } - } - return true -} - -func cloudflareStatusRecordIDs(recordSet *dnsv1alpha1.RecordSet) map[string]struct{} { - statusData, err := cloudflareRecordSetStatusData(recordSet) - if err != nil { - return nil - } - out := make(map[string]struct{}, len(statusData.Records)) - for _, record := range statusData.Records { - if record.ID != "" { - out[record.ID] = struct{}{} - } - } - return out -} - -func cloudflareDNSRecordStatuses(records []CloudflareDNSRecord) []cloudflarev1alpha1.CloudflareDNSRecordStatus { - statuses := make([]cloudflarev1alpha1.CloudflareDNSRecordStatus, 0, len(records)) - for _, record := range records { - statuses = append(statuses, cloudflareDNSRecordStatus(record)) - } - return statuses -} - -func cloudflareDNSRecordStatus(record CloudflareDNSRecord) cloudflarev1alpha1.CloudflareDNSRecordStatus { - return cloudflarev1alpha1.CloudflareDNSRecordStatus{ - ID: record.ID, - } -} - -func cloudflareDNSRecordStates(records []CloudflareDNSRecord) []cloudflarev1alpha1.CloudflareDNSRecordState { - states := make([]cloudflarev1alpha1.CloudflareDNSRecordState, 0, len(records)) - for _, record := range records { - states = append(states, cloudflareDNSRecordState(record)) - } - return states -} - -func cloudflareDNSRecordState(record CloudflareDNSRecord) cloudflarev1alpha1.CloudflareDNSRecordState { - return cloudflarev1alpha1.CloudflareDNSRecordState{ - ID: record.ID, - Type: record.Type, - Name: record.Name, - Content: record.Content, - Priority: record.Priority, - TTL: record.TTL, - Proxied: record.Proxied, - Proxiable: record.Proxiable, - Comment: record.Comment, - Tags: slices.Clone(record.Tags), - } -} - -func cloudflareRecordStatusIDs(records []cloudflarev1alpha1.CloudflareDNSRecordStatus) []string { - ids := make([]string, 0, len(records)) - for _, record := range records { - if record.ID != "" { - ids = append(ids, record.ID) - } - } - return ids -} - -func cloudflareRecordStatusIDsMatchAdoption(statuses []cloudflarev1alpha1.CloudflareDNSRecordStatus, adoptionIDs []string) bool { - statusIDs := cloudflareRecordStatusIDs(statuses) - if len(statusIDs) != len(adoptionIDs) { - return false - } - expected := make(map[string]struct{}, len(adoptionIDs)) - for _, id := range adoptionIDs { - expected[id] = struct{}{} - } - for _, id := range statusIDs { - if _, ok := expected[id]; !ok { - return false - } - } - return true -} - -func cloudflareRecordMatchesDeletingRecordSet(record CloudflareDNSRecord, recordSet *dnsv1alpha1.RecordSet, fullName string) bool { - return record.Type == string(recordSet.Spec.Type) && - normalizeCloudflareName(record.Name) == normalizeCloudflareName(fullName) -} - -func cloudflareRecordSetStatusData(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetStatusData, error) { - if recordSet.Status.Provider == nil || len(recordSet.Status.Provider.Data.Raw) == 0 { - return cloudflarev1alpha1.CloudflareRecordSetStatusData{}, nil - } - var data cloudflarev1alpha1.CloudflareRecordSetStatusData - if err := json.Unmarshal(recordSet.Status.Provider.Data.Raw, &data); err != nil { - return cloudflarev1alpha1.CloudflareRecordSetStatusData{}, fmt.Errorf("RecordSet status.provider.data must match Cloudflare schema: %w", err) - } - if len(data.Records) > 0 { - if err := validateCloudflareRecordStatusIDs(data.Records, "RecordSet status.provider.data.records.id"); err != nil { - return cloudflarev1alpha1.CloudflareRecordSetStatusData{}, err - } - } - return data, nil -} - -func cloudflareRecordSetState(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetState, error) { - if recordSet.Status.Provider == nil || len(recordSet.Status.Provider.State.Raw) == 0 { - return cloudflarev1alpha1.CloudflareRecordSetState{}, nil - } - var state cloudflarev1alpha1.CloudflareRecordSetState - if err := json.Unmarshal(recordSet.Status.Provider.State.Raw, &state); err != nil { - return cloudflarev1alpha1.CloudflareRecordSetState{}, fmt.Errorf("RecordSet status.provider.state must match Cloudflare schema: %w", err) - } - return state, nil -} - -func cloudflareRecordSetAdoptionRef(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetAdoption, bool, error) { - if len(recordSet.Spec.Adoption.Raw) == 0 { - return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, false, nil - } - var externalRef cloudflarev1alpha1.CloudflareRecordSetAdoption - if err := json.Unmarshal(recordSet.Spec.Adoption.Raw, &externalRef); err != nil { - return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, true, fmt.Errorf("adoption must be an object with recordIDs: %w", err) - } - if len(externalRef.RecordIDs) == 0 { - return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, true, errors.New("adoption.recordIDs must not be empty") - } - if err := validateCloudflareUniqueIDs(externalRef.RecordIDs, "adoption.recordIDs"); err != nil { - return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, true, err - } - return externalRef, true, nil -} - -func cloudflareFullRecordName(ownerName, domainName string) string { - switch ownerName { - case "@": - return domainName - case "*": - return "*." + domainName - default: - return ownerName + "." + domainName - } -} - -func cloudflareZoneUnitOwnsRecordSet(recordSet *dnsv1alpha1.RecordSet, unit *dnsv1alpha1.ZoneUnit) (bool, bool) { - if unit == nil { - return false, false - } - refKey := cloudflareRecordSetClaimKey(recordSet.Namespace, recordSet.Name) - for _, item := range unit.Spec.RecordSets { - if cloudflareRecordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) == refKey { - if item.Name == recordSet.Spec.Name && item.Type == recordSet.Spec.Type { - return true, false - } - return false, true - } - } - for _, item := range unit.Spec.RecordSets { - if item.Name != recordSet.Spec.Name { - continue - } - if item.Type == recordSet.Spec.Type || item.Type == dnsv1alpha1.RecordTypeCNAME || recordSet.Spec.Type == dnsv1alpha1.RecordTypeCNAME { - return false, true - } - } - return false, false -} - -func cloudflareRecordSetClaimKey(namespace, name string) string { - return namespace + "\x00" + name -} - -func normalizeCloudflareName(value string) string { - return strings.TrimSuffix(value, ".") -} - -func recordSetZoneKey(recordSet *dnsv1alpha1.RecordSet) (string, string) { - namespace := recordSet.Namespace - if recordSet.Spec.ZoneRef.Namespace != nil && *recordSet.Spec.ZoneRef.Namespace != "" { - namespace = *recordSet.Spec.ZoneRef.Namespace - } - return namespace, recordSet.Spec.ZoneRef.Name -} - -func fullRecordNamePatternMatch(pattern, recordName string) (bool, error) { - compiled, err := regexp.Compile(pattern) - if err != nil { - return false, err - } - match := compiled.FindStringIndex(recordName) - return match != nil && match[0] == 0 && match[1] == len(recordName), nil -} - -func int32PtrValue(value *int32) int32 { - if value == nil { - return 0 - } - return *value -} - -func boolPtrValue(value *bool) bool { - return value != nil && *value -} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset_fixtures_test.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_fixtures_test.go new file mode 100644 index 0000000..be0ba21 --- /dev/null +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_fixtures_test.go @@ -0,0 +1,229 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "fmt" + "slices" + + cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const cloudflareTestRecordSetUID = "4edb2097-97b0-4a1f-92be-2bd8783fe4d8" + +func cloudflareRecordSetProvider() *dnsv1alpha1.Provider { + provider := cloudflareProvider() + provider.Spec.Versions[0].RecordSet.SupportedTypes = []dnsv1alpha1.RecordType{ + dnsv1alpha1.RecordTypeA, + dnsv1alpha1.RecordTypeAAAA, + dnsv1alpha1.RecordTypeTXT, + dnsv1alpha1.RecordTypeCNAME, + dnsv1alpha1.RecordTypeMX, + dnsv1alpha1.RecordTypeCAA, + dnsv1alpha1.RecordTypeNS, + } + return provider +} + +func cloudflareProgrammedZone(namespace, name string) *dnsv1alpha1.Zone { + zone := cloudflareZone(namespace, name) + raw, err := json.Marshal(cloudflarev1alpha1.CloudflareZoneStatusData{ + Zone: cloudflarev1alpha1.CloudflareZoneStatus{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Status: "pending", + Type: "full", + }, + }) + if err != nil { + panic(err) + } + zone.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: raw}} + zone.Status.Conditions = []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + } + return zone +} + +func cloudflareARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + ttl := int32(300) + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: cloudflareTestRecordSetUID, Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: cloudflarev1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeA, + Name: "www", + TTL: &ttl, + A: &dnsv1alpha1.ARecordSet{Addresses: []string{"192.0.2.10", "192.0.2.11"}}, + }, + } +} + +func cloudflareCNAMERecordSet(namespace, name, target string) *dnsv1alpha1.RecordSet { + ttl := int32(300) + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: cloudflareTestRecordSetUID, Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: cloudflarev1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeCNAME, + Name: "www", + TTL: &ttl, + CNAME: &dnsv1alpha1.CNAMERecordSet{Target: target}, + }, + } +} + +func cloudflareTXTRecordSetWithOptions(namespace, name string, options map[string]any) *dnsv1alpha1.RecordSet { + raw, err := json.Marshal(options) + if err != nil { + panic(err) + } + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: cloudflareTestRecordSetUID, Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: cloudflarev1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeTXT, + Name: "txt", + Options: runtime.RawExtension{Raw: raw}, + TXT: &dnsv1alpha1.TXTRecordSet{Values: []string{"hello"}}, + }, + } +} + +func cloudflareZoneUnitWithRecordSetItem(zoneNamespace, zoneName, recordSetNamespace, recordSetName, recordName string, recordType dnsv1alpha1.RecordType) *dnsv1alpha1.ZoneUnit { + return &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{Namespace: zoneNamespace, Name: zoneName}, + Spec: dnsv1alpha1.ZoneUnitSpec{ + Provider: cloudflarev1alpha1.ProviderRef, + Zone: dnsv1alpha1.ZoneUnitZoneSpec{ + Ref: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: zoneName}, + ObservedGeneration: 1, + DomainName: "apps.example.com", + ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: "platform", Name: "cloudflare-public"}, + }, + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + { + RecordSetNamespace: recordSetNamespace, + RecordSetName: recordSetName, + RecordSetUID: cloudflareTestRecordSetUID, + Name: recordName, + Type: recordType, + }, + }, + }, + } +} + +type fakeCloudflareZoneRecordProvider struct { + *fakeCloudflareZoneProvider + *fakeCloudflareRecordSetProvider +} + +type fakeCloudflareRecordSetProvider struct { + created []CloudflareDNSRecord + patched []CloudflareDNSRecord + deleted []string + batches []CloudflareDNSRecordBatch + batchErr error + records []CloudflareDNSRecord + createIDs []string + recordsByID map[string]CloudflareDNSRecord +} + +func (p *fakeCloudflareRecordSetProvider) ListDNSRecords(_ context.Context, _, name string) ([]CloudflareDNSRecord, error) { + var out []CloudflareDNSRecord + for _, record := range p.records { + if normalizeCloudflareName(record.Name) == normalizeCloudflareName(name) { + out = append(out, record) + } + } + return out, nil +} + +func (p *fakeCloudflareRecordSetProvider) GetDNSRecord(_ context.Context, _, recordID string) (CloudflareDNSRecord, error) { + if p.recordsByID != nil { + if record, ok := p.recordsByID[recordID]; ok { + return record, nil + } + } + for _, record := range p.records { + if record.ID == recordID { + return record, nil + } + } + return CloudflareDNSRecord{}, &identityReasonError{reason: "ExternalResourceNotFound", message: "not found"} +} + +func (p *fakeCloudflareRecordSetProvider) CreateDNSRecord(_ context.Context, _ string, record CloudflareDNSRecord) (CloudflareDNSRecord, error) { + if len(p.createIDs) > len(p.created) { + record.ID = p.createIDs[len(p.created)] + } else { + record.ID = fmt.Sprintf("023e105f4ecef8ad9ca31a8372d0c%03d", len(p.created)) + } + p.created = append(p.created, record) + p.records = append(p.records, record) + return record, nil +} + +func (p *fakeCloudflareRecordSetProvider) PatchDNSRecord(_ context.Context, _ string, recordID string, record CloudflareDNSRecord) (CloudflareDNSRecord, error) { + record.ID = recordID + p.patched = append(p.patched, record) + for index := range p.records { + if p.records[index].ID == recordID { + p.records[index] = record + return record, nil + } + } + p.records = append(p.records, record) + return record, nil +} + +func (p *fakeCloudflareRecordSetProvider) DeleteDNSRecord(_ context.Context, _, recordID string) error { + p.deleted = append(p.deleted, recordID) + p.records = slices.DeleteFunc(p.records, func(record CloudflareDNSRecord) bool { + return record.ID == recordID + }) + return nil +} + +func (p *fakeCloudflareRecordSetProvider) BatchDNSRecords(ctx context.Context, zoneID string, batch CloudflareDNSRecordBatch) (CloudflareDNSRecordBatch, error) { + p.batches = append(p.batches, batch) + if p.batchErr != nil { + return CloudflareDNSRecordBatch{}, p.batchErr + } + out := CloudflareDNSRecordBatch{ + Deletes: slices.Clone(batch.Deletes), + Patches: make([]CloudflareDNSRecord, 0, len(batch.Patches)), + Posts: make([]CloudflareDNSRecord, 0, len(batch.Posts)), + } + for _, record := range batch.Deletes { + if err := p.DeleteDNSRecord(ctx, zoneID, record.ID); err != nil { + return CloudflareDNSRecordBatch{}, err + } + } + for _, record := range batch.Patches { + updated, err := p.PatchDNSRecord(ctx, zoneID, record.ID, record) + if err != nil { + return CloudflareDNSRecordBatch{}, err + } + out.Patches = append(out.Patches, updated) + } + for _, record := range batch.Posts { + created, err := p.CreateDNSRecord(ctx, zoneID, record) + if err != nil { + return CloudflareDNSRecordBatch{}, err + } + out.Posts = append(out.Posts, created) + } + return out, nil +} + +func ptrBool(value bool) *bool { + return &value +} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset_lifecycle_test.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_lifecycle_test.go new file mode 100644 index 0000000..bfbbe90 --- /dev/null +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_lifecycle_test.go @@ -0,0 +1,580 @@ +package cloudflare + +import ( + "context" + "testing" + + cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/client/fake" +) + +func TestCloudflareRecordSetReconcilerDoesNotCompleteDeletionFromUnownedStatus(t *testing.T) { + for _, tt := range []struct { + name string + statusUID types.UID + }{ + {name: "missing UID"}, + {name: "predecessor UID", statusUID: types.UID("386cde02-3890-4ee6-a01a-037ed8354b5d")}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.UID = types.UID("42f149e7-107d-48bb-885c-7a6c539678a6") + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, true) + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: tt.statusUID, + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })}, + DeletionCompleted: true, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: recordSet.Generation}, + }, + }, + } + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{external}} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.batches) != 0 || len(provider.deleted) != 0 { + t.Fatalf("provider deletion requests = batches %#v deleted %#v, want none", provider.batches, provider.deleted) + } + + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + status := cloudflareLifecycleRecordSetStatus(t, &got, recordSet) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("status recordSetUID = %q, want %q", status.RecordSetUID, recordSet.UID) + } + if status.Provider != nil { + t.Fatalf("unowned provider status was retained: %#v", status.Provider) + } + if status.DeletionCompleted { + t.Fatalf("deletion completed from unowned status") + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderOwnershipNotEstablished") + }) + } +} + +func TestCloudflareRecordSetReconcilerCompletesDeletionForCurrentUID(t *testing.T) { + ctx := context.Background() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.UID = types.UID("649f11b6-03b1-44ec-b433-578042cbddfb") + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, true) + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: recordSet.Generation}, + }, + }, + } + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{external}} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatalf("Reconcile returned error deleting current record: %v", err) + } + if len(provider.deleted) != 1 || provider.deleted[0] != external.ID { + t.Fatalf("deleted records = %#v, want %q", provider.deleted, external.ID) + } + var afterDelete dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &afterDelete); err != nil { + t.Fatalf("ZoneUnit was not found after provider delete: %v", err) + } + if status := cloudflareLifecycleRecordSetStatus(t, &afterDelete, recordSet); status.DeletionCompleted { + t.Fatalf("deletion completed before fresh absence was observed") + } + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, &afterDelete); err != nil { + t.Fatalf("Reconcile returned error confirming deletion: %v", err) + } + var complete dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &complete); err != nil { + t.Fatalf("ZoneUnit was not found after deletion confirmation: %v", err) + } + status := cloudflareLifecycleRecordSetStatus(t, &complete, recordSet) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("status recordSetUID = %q, want %q", status.RecordSetUID, recordSet.UID) + } + if !status.DeletionCompleted { + t.Fatalf("deletion did not complete for current UID") + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") +} + +func TestCloudflareRecordSetReconcilerCompletesDeletionAfterAlreadyAbsentRecord(t *testing.T) { + ctx := context.Background() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.UID = types.UID("649f11b6-03b1-44ec-b433-578042cbddfb") + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, true) + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "RecordSet is accepted by Cloudflare policy", ObservedGeneration: recordSet.Generation}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "Cloudflare DNS record deletion is complete", ObservedGeneration: recordSet.Generation}, + }, + }} + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + reconciler := &recordSetReconciler{Client: k8sClient, Provider: &fakeCloudflareRecordSetProvider{}} + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatalf("Reconcile returned error after the record was already absent: %v", err) + } + var afterAbsent dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &afterAbsent); err != nil { + t.Fatalf("ZoneUnit was not found after absence observation: %v", err) + } + status := cloudflareLifecycleRecordSetStatus(t, &afterAbsent, recordSet) + if status.DeletionCompleted { + t.Fatalf("deletion completed before a fresh empty list") + } + data, err := cloudflareRecordSetStatusDataFromProvider(status.Provider) + if err != nil { + t.Fatalf("status provider data error after absence observation: %v", err) + } + if len(data.Records) != 0 { + t.Fatalf("status record IDs after absence observation = %#v, want none", data.Records) + } + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, &afterAbsent); err != nil { + t.Fatalf("Reconcile returned error confirming deletion completion: %v", err) + } + var complete dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &complete); err != nil { + t.Fatalf("ZoneUnit was not found after deletion completion: %v", err) + } + status = cloudflareLifecycleRecordSetStatus(t, &complete, recordSet) + if !status.DeletionCompleted { + t.Fatalf("deletion did not complete after fresh absence") + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") +} + +func TestCloudflareRecordSetReconcilerDoesNotMutateExternalRecordFromUnownedStatus(t *testing.T) { + ctx := context.Background() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.UID = types.UID("4c574ddc-af01-4b99-9096-ea35eec1c7ec") + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, false) + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: types.UID("e4a31b74-b40e-4b16-ad30-b2c8a924351a"), + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })}, + }, + } + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{external}} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.batches) != 0 || len(provider.patched) != 0 || len(provider.deleted) != 0 { + t.Fatalf("provider mutations = batches %#v patched %#v deleted %#v, want none", provider.batches, provider.patched, provider.deleted) + } + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + status := cloudflareLifecycleRecordSetStatus(t, &got, recordSet) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("status recordSetUID = %q, want %q", status.RecordSetUID, recordSet.UID) + } + if status.Provider != nil { + t.Fatalf("unowned provider status was retained: %#v", status.Provider) + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderConflict") +} + +func TestCloudflareRecordSetReconcilerAdoptsForCurrentUIDAfterUnownedReceipt(t *testing.T) { + ctx := context.Background() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.UID = types.UID("b75fc063-442f-4d44-8812-4ffcf2799897") + recordSet.Spec.A.Addresses = []string{"192.0.2.10"} + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + recordSet.Spec.Adoption = cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetAdoption{RecordIDs: []string{external.ID}}) + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, false) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: types.UID("e4a31b74-b40e-4b16-ad30-b2c8a924351a"), + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })}, + }, + } + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{external}} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.batches) != 0 { + t.Fatalf("provider mutations = %#v, want none", provider.batches) + } + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + status := cloudflareLifecycleRecordSetStatus(t, &got, recordSet) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("status recordSetUID = %q, want %q", status.RecordSetUID, recordSet.UID) + } + data, err := cloudflareRecordSetStatusDataFromProvider(status.Provider) + if err != nil { + t.Fatalf("status provider data error: %v", err) + } + if len(data.Records) != 1 || data.Records[0].ID != external.ID { + t.Fatalf("adopted record IDs = %#v", data.Records) + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") +} + +func TestCloudflareRecordSetMutationFenceRejectsChangedSource(t *testing.T) { + for _, tt := range []struct { + name string + replace func(*dnsv1alpha1.ZoneUnit) + }{ + { + name: "same UID newer generation", + replace: func(unit *dnsv1alpha1.ZoneUnit) { + unit.Spec.RecordSets[0].ObservedGeneration++ + }, + }, + { + name: "replacement UID", + replace: func(unit *dnsv1alpha1.ZoneUnit) { + unit.Spec.RecordSets[0].RecordSetUID = types.UID("b57bc8fe-7a0d-4f89-9dc4-296c8a002f06") + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + unit := cloudflareMutationFenceUnit(recordSet, zone) + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + + var stale dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &stale); err != nil { + t.Fatalf("get ZoneUnit source snapshot: %v", err) + } + current := stale.DeepCopy() + tt.replace(current) + if err := k8sClient.Update(ctx, current); err != nil { + t.Fatalf("replace ZoneUnit source: %v", err) + } + + record := cloudflareRecordSetFromZoneUnitItem(&stale, stale.Spec.RecordSets[0], stale.Status.RecordSets[0]) + provider := &fakeCloudflareRecordSetProvider{} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + ctxData := cloudflareRecordSetContext{ + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + RecordSetSource: stale.Spec.RecordSets[0].DeepCopy(), + } + if _, err := reconciler.applyCloudflareRecordSetDiff(ctx, provider, &record, ctxData, nil, []CloudflareDNSRecord{{Type: "A"}}); !apierrors.IsConflict(err) { + t.Fatalf("mutation fence error = %v, want source conflict", err) + } + if len(provider.batches) != 0 { + t.Fatalf("changed source reached Cloudflare batch write: %#v", provider.batches) + } + }) + } +} + +func TestCloudflareRecordSetDeletionMutationFenceRejectsChangedSource(t *testing.T) { + ctx := t.Context() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + unit := cloudflareMutationFenceUnit(recordSet, zone) + unit.Spec.RecordSets[0].DeletionRequested = true + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + unit.Status.RecordSets[0].Provider = &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })} + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + + var stale dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &stale); err != nil { + t.Fatalf("get ZoneUnit source snapshot: %v", err) + } + current := stale.DeepCopy() + current.Spec.RecordSets[0].ObservedGeneration++ + if err := k8sClient.Update(ctx, current); err != nil { + t.Fatalf("replace ZoneUnit source: %v", err) + } + + record := cloudflareRecordSetFromZoneUnitItem(&stale, stale.Spec.RecordSets[0], stale.Status.RecordSets[0]) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{external}} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + ctxData := cloudflareRecordSetContext{ + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + FullName: "www.apps.example.com", + RecordSetSource: stale.Spec.RecordSets[0].DeepCopy(), + } + if _, err := reconciler.reconcileDeleteWithContext(ctx, &record, ctxData); !apierrors.IsConflict(err) { + t.Fatalf("deletion mutation fence error = %v, want source conflict", err) + } + if len(provider.batches) != 0 { + t.Fatalf("changed source reached Cloudflare delete batch: %#v", provider.batches) + } +} + +func TestCloudflareZoneReconcilerDoesNotMutateFromStaleRecordSetSource(t *testing.T) { + for _, tt := range []struct { + name string + replace func(*dnsv1alpha1.ZoneUnit) + }{ + { + name: "same UID newer generation", + replace: func(unit *dnsv1alpha1.ZoneUnit) { + unit.Spec.RecordSets[0].ObservedGeneration++ + }, + }, + { + name: "replacement UID", + replace: func(unit *dnsv1alpha1.ZoneUnit) { + unit.Spec.RecordSets[0].RecordSetUID = types.UID("a4d5c1fa-7dca-47cb-a2ff-b87e2bed9609") + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + unit := cloudflareMutationFenceUnit(recordSet, zone) + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + + var stale dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &stale); err != nil { + t.Fatalf("get ZoneUnit cache snapshot: %v", err) + } + current := stale.DeepCopy() + tt.replace(current) + if err := k8sClient.Update(ctx, current); err != nil { + t.Fatalf("replace ZoneUnit backing source: %v", err) + } + + provider := &fakeCloudflareZoneRecordProvider{ + fakeCloudflareZoneProvider: &fakeCloudflareZoneProvider{ + zonesByID: map[string]CloudflareZone{ + "023e105f4ecef8ad9ca31a8372d0c353": { + ID: "023e105f4ecef8ad9ca31a8372d0c353", + AccountID: "023e105f4ecef8ad9ca31a8372d0c354", + Name: "apps.example.com", + Status: "pending", + Type: "full", + }, + }, + }, + fakeCloudflareRecordSetProvider: &fakeCloudflareRecordSetProvider{}, + } + reconciler := &ZoneReconciler{ + Client: &cloudflareStaleZoneUnitClient{ + Client: k8sClient, + staleZoneUnit: stale.DeepCopy(), + }, + Provider: provider, + } + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); !apierrors.IsConflict(err) { + t.Fatalf("reconcile error = %v, want resourceVersion conflict", err) + } + if len(provider.batches) != 0 { + t.Fatalf("stale cache reached Cloudflare batch write: %#v", provider.batches) + } + }) + } +} + +func cloudflareMutationFenceUnit(recordSet *dnsv1alpha1.RecordSet, zone *dnsv1alpha1.Zone) *dnsv1alpha1.ZoneUnit { + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, false) + unit.Generation = 1 + unit.Status.ObservedGeneration = unit.Generation + unit.Status.Zone.Conditions = []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "Zone is accepted by Cloudflare policy", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "Cloudflare zone matches desired state", ObservedGeneration: 1}, + } + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "RecordSet is accepted by Cloudflare policy", ObservedGeneration: recordSet.Generation}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionFalse, Reason: "ProviderChangePending", Message: "Cloudflare DNS record change is pending", ObservedGeneration: recordSet.Generation}, + }, + }} + unit.Status.Conditions = []metav1.Condition{{ + Type: string(dnsv1alpha1.ConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: "ProviderChangePending", + Message: "Cloudflare DNS record change is pending", + ObservedGeneration: unit.Generation, + }} + return unit +} + +type cloudflareStaleZoneUnitClient struct { + client.Client + staleZoneUnit *dnsv1alpha1.ZoneUnit +} + +func (c *cloudflareStaleZoneUnitClient) Get(ctx context.Context, key client.ObjectKey, object client.Object, options ...client.GetOption) error { + if unit, ok := object.(*dnsv1alpha1.ZoneUnit); ok && key == client.ObjectKeyFromObject(c.staleZoneUnit) { + c.staleZoneUnit.DeepCopyInto(unit) + return nil + } + return c.Client.Get(ctx, key, object, options...) +} + +func cloudflareRecordSetLifecycleUnit(recordSet *dnsv1alpha1.RecordSet, zone *dnsv1alpha1.Zone, deleting bool) *dnsv1alpha1.ZoneUnit { + unit := cloudflareZoneUnitWithRecordSetItem("app", "apps-example-com", recordSet.Namespace, recordSet.Name, recordSet.Spec.Name, recordSet.Spec.Type) + item := cloudflareZoneUnitRecordSetSpec(recordSet) + item.DeletionRequested = deleting + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{item} + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{ + Provider: zone.Status.Provider, + Conditions: zone.Status.Conditions, + } + return unit +} + +func cloudflareRecordSetLifecycleClient(t *testing.T, zone *dnsv1alpha1.Zone, unit *dnsv1alpha1.ZoneUnit) client.Client { + t.Helper() + return fake.NewClientBuilder(). + WithScheme(cloudflareTestScheme(t)). + WithObjects( + cloudflareRecordSetProvider(), + acceptedReadyCloudflareIdentity("platform", "cloudflare-ci"), + acceptedCloudflareZoneClass("platform", "cloudflare-public"), + zone, + unit, + ). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.ZoneClass{}, &dnsv1alpha1.ZoneUnit{}, &cloudflarev1alpha1.CloudflareIdentity{}). + Build() +} + +func cloudflareLifecycleARecord(id string) CloudflareDNSRecord { + ttl := int32(300) + proxied := false + return CloudflareDNSRecord{ + ID: id, + Type: "A", + Name: "www.apps.example.com", + Content: "192.0.2.10", + TTL: &ttl, + Proxied: &proxied, + } +} + +func cloudflareLifecycleRecordSetStatus(t *testing.T, unit *dnsv1alpha1.ZoneUnit, recordSet *dnsv1alpha1.RecordSet) dnsv1alpha1.ZoneUnitRecordSetStatus { + t.Helper() + for _, status := range unit.Status.RecordSets { + if status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name { + return status + } + } + t.Fatalf("ZoneUnit status did not contain RecordSet status: %#v", unit.Status.RecordSets) + return dnsv1alpha1.ZoneUnitRecordSetStatus{} +} + +func TestCloudflareRecordSetRetainsOwnershipAcrossGenerationUpdate(t *testing.T) { + ctx := t.Context() + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.Generation = 2 + ttl := int32(600) + recordSet.Spec.TTL = &ttl + recordSet.Spec.A.Addresses = []string{"192.0.2.10"} + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, false) + external := cloudflareLifecycleARecord("023e105f4ecef8ad9ca31a8372d0c010") + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: 1, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{external}), + })}, + Conditions: []metav1.Condition{{ + Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, + Reason: "Programmed", ObservedGeneration: 1, + }}, + }} + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{external}} + reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatal(err) + } + if len(provider.records) != 1 || provider.records[0].ID != external.ID || + provider.records[0].TTL == nil || *provider.records[0].TTL != ttl { + t.Fatalf("same-UID generation update did not update the existing provider record: %#v", provider.records) + } +} + +func TestCloudflareAggregateRequiresCurrentProgrammedObservation(t *testing.T) { + zone := cloudflareProgrammedZone("app", "apps-example-com") + recordSet := cloudflareARecordSet("app", "www-a") + recordSet.Generation = 2 + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, false) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: 2, + Conditions: []metav1.Condition{{ + Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, + Reason: "Programmed", ObservedGeneration: 1, + }}, + }} + if status, _, _ := zoneUnitProgrammedCondition(unit); status == metav1.ConditionTrue { + t.Fatal("an Accepted refresh promoted the previous generation's Programmed observation") + } +} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset_records.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_records.go new file mode 100644 index 0000000..019e6bc --- /dev/null +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_records.go @@ -0,0 +1,475 @@ +package cloudflare + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + + cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" +) + +var cloudflareTagPattern = regexp.MustCompile(`^([A-Za-z0-9_-]{1,32}):(.*)$`) + +func cloudflareRecordSetOptions(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetOptions, error) { + if len(recordSet.Spec.Options.Raw) == 0 { + return cloudflarev1alpha1.CloudflareRecordSetOptions{}, nil + } + var options cloudflarev1alpha1.CloudflareRecordSetOptions + if err := json.Unmarshal(recordSet.Spec.Options.Raw, &options); err != nil { + return cloudflarev1alpha1.CloudflareRecordSetOptions{}, fmt.Errorf("options must match Cloudflare RecordSet schema: %w", err) + } + return options, nil +} + +func validateCloudflareRecordSetOptions(recordSet *dnsv1alpha1.RecordSet, options cloudflarev1alpha1.CloudflareRecordSetOptions) string { + if options.TTL != "" && options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto { + return "options.ttl must be Auto" + } + if options.TTL != "" && recordSet.Spec.TTL != nil { + return "ttl must not be specified when cloudflare automatic ttl is used" + } + if options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto { + if recordSet.Spec.TTL == nil { + return "ttl is required for Cloudflare records unless options.ttl is Auto" + } + if !cloudflareFixedTTLAllowed(*recordSet.Spec.TTL) { + return "cloudflare fixed ttl must be between 60 and 86400 seconds" + } + } + if options.Proxied != nil && *options.Proxied { + if recordSet.Spec.Type != dnsv1alpha1.RecordTypeA && recordSet.Spec.Type != dnsv1alpha1.RecordTypeAAAA && recordSet.Spec.Type != dnsv1alpha1.RecordTypeCNAME { + return "cloudflare proxied is supported only for A, AAAA, and CNAME records" + } + if options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto || recordSet.Spec.TTL != nil { + return "cloudflare proxied records must use automatic ttl" + } + } + for _, tag := range options.Tags { + matches := cloudflareTagPattern.FindStringSubmatch(tag) + if matches == nil || strings.ContainsAny(matches[2], "\r\n") || len(matches[2]) > 100 { + return "cloudflare tags must use name:value format" + } + if strings.HasPrefix(strings.ToLower(matches[1]), "cf-") { + return "cloudflare tag names starting with cf- are reserved" + } + } + seenTags := map[string]struct{}{} + for _, tag := range options.Tags { + matches := cloudflareTagPattern.FindStringSubmatch(tag) + if matches == nil { + continue + } + key := strings.ToLower(matches[1]) + ":" + matches[2] + if _, ok := seenTags[key]; ok { + return "cloudflare tags must not contain duplicate name:value pairs" + } + seenTags[key] = struct{}{} + } + return "" +} + +func desiredCloudflareDNSRecords(recordSet *dnsv1alpha1.RecordSet, fullName string, options cloudflarev1alpha1.CloudflareRecordSetOptions) ([]CloudflareDNSRecord, error) { + ttl := int32(1) + if options.TTL != cloudflarev1alpha1.CloudflareRecordSetTTLModeAuto { + if recordSet.Spec.TTL == nil { + return nil, errors.New("ttl is required for Cloudflare records unless options.ttl is Auto") + } + if !cloudflareFixedTTLAllowed(*recordSet.Spec.TTL) { + return nil, errors.New("cloudflare fixed ttl must be between 60 and 86400 seconds") + } + ttl = *recordSet.Spec.TTL + } + base := CloudflareDNSRecord{ + Type: string(recordSet.Spec.Type), + Name: fullName, + TTL: &ttl, + Comment: options.Comment, + Tags: slices.Clone(options.Tags), + } + if recordSet.Spec.Type == dnsv1alpha1.RecordTypeA || recordSet.Spec.Type == dnsv1alpha1.RecordTypeAAAA || recordSet.Spec.Type == dnsv1alpha1.RecordTypeCNAME { + proxied := false + if options.Proxied != nil { + proxied = *options.Proxied + } + base.Proxied = &proxied + } + var records []CloudflareDNSRecord + switch recordSet.Spec.Type { + case dnsv1alpha1.RecordTypeA: + for _, address := range recordSet.Spec.A.Addresses { + record := base + record.Content = address + records = append(records, record) + } + case dnsv1alpha1.RecordTypeAAAA: + for _, address := range recordSet.Spec.AAAA.Addresses { + record := base + record.Content = address + records = append(records, record) + } + case dnsv1alpha1.RecordTypeTXT: + for _, value := range recordSet.Spec.TXT.Values { + record := base + record.Content = value + records = append(records, record) + } + case dnsv1alpha1.RecordTypeCNAME: + record := base + record.Content = recordSet.Spec.CNAME.Target + records = append(records, record) + case dnsv1alpha1.RecordTypeMX: + for _, item := range recordSet.Spec.MX.Records { + record := base + record.Content = item.Exchange + record.Priority = &item.Preference + records = append(records, record) + } + case dnsv1alpha1.RecordTypeCAA: + for _, item := range recordSet.Spec.CAA.Records { + record := base + record.Content = item.Value + record.CAA = &CloudflareCAAData{Flags: item.Flags, Tag: item.Tag, Value: item.Value} + records = append(records, record) + } + case dnsv1alpha1.RecordTypeNS: + for _, nameServer := range recordSet.Spec.NS.NameServers { + record := base + record.Content = nameServer + records = append(records, record) + } + default: + return nil, errors.New("Cloudflare supports A, AAAA, TXT, CNAME, MX, CAA, and delegated NS records") + } + slices.SortFunc(records, compareCloudflareDNSRecord) + return records, nil +} + +func cloudflareFixedTTLAllowed(ttl int32) bool { + return ttl >= cloudflareFixedTTLMin && ttl <= cloudflareFixedTTLMax +} + +func cloudflareDNSRecordSetEqual(a, b []CloudflareDNSRecord) bool { + left := slices.Clone(a) + right := slices.Clone(b) + slices.SortFunc(left, compareCloudflareDNSRecord) + slices.SortFunc(right, compareCloudflareDNSRecord) + if len(left) != len(right) { + return false + } + for index := range left { + if !cloudflareDNSRecordEqual(left[index], right[index]) { + return false + } + } + return true +} + +func cloudflareDNSRecordEqual(a, b CloudflareDNSRecord) bool { + return a.Type == b.Type && + normalizeCloudflareName(a.Name) == normalizeCloudflareName(b.Name) && + (a.Type == string(dnsv1alpha1.RecordTypeCAA) || a.Content == b.Content) && + int32PtrValue(a.Priority) == int32PtrValue(b.Priority) && + int32PtrValue(a.TTL) == int32PtrValue(b.TTL) && + boolPtrValue(a.Proxied) == boolPtrValue(b.Proxied) && + a.Comment == b.Comment && + slices.Equal(cloudflareSortedTags(a.Tags), cloudflareSortedTags(b.Tags)) && + cloudflareCAAEqual(a.CAA, b.CAA) +} + +func compareCloudflareDNSRecord(a, b CloudflareDNSRecord) int { + for _, pair := range [][2]string{ + {a.Type, b.Type}, + {normalizeCloudflareName(a.Name), normalizeCloudflareName(b.Name)}, + {a.Content, b.Content}, + {fmt.Sprintf("%05d", int32PtrValue(a.Priority)), fmt.Sprintf("%05d", int32PtrValue(b.Priority))}, + {cloudflareCAAKey(a.CAA), cloudflareCAAKey(b.CAA)}, + } { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + return 0 +} + +func cloudflareCAAEqual(a, b *CloudflareCAAData) bool { + return cloudflareCAAKey(a) == cloudflareCAAKey(b) +} + +func cloudflareCAAKey(value *CloudflareCAAData) string { + if value == nil { + return "" + } + return fmt.Sprintf("%d\x00%s\x00%s", value.Flags, value.Tag, value.Value) +} + +func cloudflareSortedTags(tags []string) []string { + out := slices.Clone(tags) + slices.Sort(out) + return out +} + +func cloudflareSameNameConflict(recordType dnsv1alpha1.RecordType, records []CloudflareDNSRecord) string { + for _, record := range records { + currentType := dnsv1alpha1.RecordType(record.Type) + if currentType == recordType { + continue + } + if recordType == dnsv1alpha1.RecordTypeCNAME || currentType == dnsv1alpha1.RecordTypeCNAME { + return "Cloudflare DNS record conflicts with same-name CNAME exclusivity" + } + if recordType == dnsv1alpha1.RecordTypeNS || currentType == dnsv1alpha1.RecordTypeNS { + return "Cloudflare DNS record conflicts with same-name delegated NS exclusivity" + } + } + return "" +} + +func cloudflareRecordsByType(records []CloudflareDNSRecord, recordType string) []CloudflareDNSRecord { + var out []CloudflareDNSRecord + for _, record := range records { + if record.Type == recordType { + out = append(out, record) + } + } + return out +} + +func cloudflareRecordsByIDs(records []CloudflareDNSRecord, ids map[string]struct{}) ([]CloudflareDNSRecord, []string) { + var out []CloudflareDNSRecord + found := make(map[string]struct{}, len(ids)) + for _, record := range records { + if _, ok := ids[record.ID]; ok { + out = append(out, record) + found[record.ID] = struct{}{} + } + } + var missing []string + for id := range ids { + if _, ok := found[id]; !ok { + missing = append(missing, id) + } + } + return out, missing +} + +func cloudflareRecordsExcludingIDs(records []CloudflareDNSRecord, ids map[string]struct{}) []CloudflareDNSRecord { + var out []CloudflareDNSRecord + for _, record := range records { + if _, ok := ids[record.ID]; !ok { + out = append(out, record) + } + } + return out +} + +func cloudflareStatusRecordIDs(recordSet *dnsv1alpha1.RecordSet) map[string]struct{} { + statusData, err := cloudflareRecordSetStatusData(recordSet) + if err != nil { + return nil + } + out := make(map[string]struct{}, len(statusData.Records)) + for _, record := range statusData.Records { + if record.ID != "" { + out[record.ID] = struct{}{} + } + } + return out +} + +func cloudflareDNSRecordStatuses(records []CloudflareDNSRecord) []cloudflarev1alpha1.CloudflareDNSRecordStatus { + statuses := make([]cloudflarev1alpha1.CloudflareDNSRecordStatus, 0, len(records)) + for _, record := range records { + statuses = append(statuses, cloudflareDNSRecordStatus(record)) + } + return statuses +} + +func cloudflareDNSRecordStatus(record CloudflareDNSRecord) cloudflarev1alpha1.CloudflareDNSRecordStatus { + return cloudflarev1alpha1.CloudflareDNSRecordStatus{ + ID: record.ID, + } +} + +func cloudflareDNSRecordStates(records []CloudflareDNSRecord) []cloudflarev1alpha1.CloudflareDNSRecordState { + states := make([]cloudflarev1alpha1.CloudflareDNSRecordState, 0, len(records)) + for _, record := range records { + states = append(states, cloudflareDNSRecordState(record)) + } + return states +} + +func cloudflareDNSRecordState(record CloudflareDNSRecord) cloudflarev1alpha1.CloudflareDNSRecordState { + return cloudflarev1alpha1.CloudflareDNSRecordState{ + ID: record.ID, + Type: record.Type, + Name: record.Name, + Content: record.Content, + Priority: record.Priority, + TTL: record.TTL, + Proxied: record.Proxied, + Proxiable: record.Proxiable, + Comment: record.Comment, + Tags: slices.Clone(record.Tags), + } +} + +func cloudflareRecordStatusIDs(records []cloudflarev1alpha1.CloudflareDNSRecordStatus) []string { + ids := make([]string, 0, len(records)) + for _, record := range records { + if record.ID != "" { + ids = append(ids, record.ID) + } + } + return ids +} + +func cloudflareRecordStatusIDsMatchAdoption(statuses []cloudflarev1alpha1.CloudflareDNSRecordStatus, adoptionIDs []string) bool { + statusIDs := cloudflareRecordStatusIDs(statuses) + if len(statusIDs) != len(adoptionIDs) { + return false + } + expected := make(map[string]struct{}, len(adoptionIDs)) + for _, id := range adoptionIDs { + expected[id] = struct{}{} + } + for _, id := range statusIDs { + if _, ok := expected[id]; !ok { + return false + } + } + return true +} + +func cloudflareRecordMatchesDeletingRecordSet(record CloudflareDNSRecord, recordSet *dnsv1alpha1.RecordSet, fullName string) bool { + return record.Type == string(recordSet.Spec.Type) && + normalizeCloudflareName(record.Name) == normalizeCloudflareName(fullName) +} + +func cloudflareRecordSetStatusData(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetStatusData, error) { + return cloudflareRecordSetStatusDataFromProvider(recordSet.Status.Provider) +} + +func cloudflareRecordSetStatusDataFromProvider(provider *dnsv1alpha1.ProviderStatus) (cloudflarev1alpha1.CloudflareRecordSetStatusData, error) { + if provider == nil || len(provider.Data.Raw) == 0 { + return cloudflarev1alpha1.CloudflareRecordSetStatusData{}, nil + } + var data cloudflarev1alpha1.CloudflareRecordSetStatusData + if err := json.Unmarshal(provider.Data.Raw, &data); err != nil { + return cloudflarev1alpha1.CloudflareRecordSetStatusData{}, fmt.Errorf("RecordSet status.provider.data must match Cloudflare schema: %w", err) + } + if len(data.Records) > 0 { + if err := validateCloudflareRecordStatusIDs(data.Records, "RecordSet status.provider.data.records.id"); err != nil { + return cloudflarev1alpha1.CloudflareRecordSetStatusData{}, err + } + } + return data, nil +} + +func cloudflareRecordSetStateFromProvider(provider *dnsv1alpha1.ProviderStatus) (cloudflarev1alpha1.CloudflareRecordSetState, error) { + if provider == nil || len(provider.State.Raw) == 0 { + return cloudflarev1alpha1.CloudflareRecordSetState{}, nil + } + var state cloudflarev1alpha1.CloudflareRecordSetState + if err := json.Unmarshal(provider.State.Raw, &state); err != nil { + return cloudflarev1alpha1.CloudflareRecordSetState{}, fmt.Errorf("RecordSet status.provider.state must match Cloudflare schema: %w", err) + } + return state, nil +} + +func cloudflareRecordSetAdoptionRef(recordSet *dnsv1alpha1.RecordSet) (cloudflarev1alpha1.CloudflareRecordSetAdoption, bool, error) { + if len(recordSet.Spec.Adoption.Raw) == 0 { + return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, false, nil + } + var externalRef cloudflarev1alpha1.CloudflareRecordSetAdoption + if err := json.Unmarshal(recordSet.Spec.Adoption.Raw, &externalRef); err != nil { + return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, true, fmt.Errorf("adoption must be an object with recordIDs: %w", err) + } + if len(externalRef.RecordIDs) == 0 { + return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, true, errors.New("adoption.recordIDs must not be empty") + } + if err := validateCloudflareUniqueIDs(externalRef.RecordIDs, "adoption.recordIDs"); err != nil { + return cloudflarev1alpha1.CloudflareRecordSetAdoption{}, true, err + } + return externalRef, true, nil +} + +func cloudflareFullRecordName(ownerName, domainName string) string { + switch ownerName { + case "@": + return domainName + case "*": + return "*." + domainName + default: + return ownerName + "." + domainName + } +} + +func cloudflareZoneUnitOwnsRecordSet(recordSet *dnsv1alpha1.RecordSet, unit *dnsv1alpha1.ZoneUnit) (bool, bool) { + if unit == nil { + return false, false + } + refKey := cloudflareRecordSetClaimKey(recordSet.Namespace, recordSet.Name) + for _, item := range unit.Spec.RecordSets { + if cloudflareRecordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) == refKey { + if item.RecordSetUID != "" && + item.RecordSetUID == recordSet.UID && + item.Name == recordSet.Spec.Name && + item.Type == recordSet.Spec.Type { + return true, false + } + return false, true + } + } + for _, item := range unit.Spec.RecordSets { + if item.Name != recordSet.Spec.Name { + continue + } + if item.Type == recordSet.Spec.Type || item.Type == dnsv1alpha1.RecordTypeCNAME || recordSet.Spec.Type == dnsv1alpha1.RecordTypeCNAME { + return false, true + } + } + return false, false +} + +func cloudflareRecordSetClaimKey(namespace, name string) string { + return namespace + "\x00" + name +} + +func normalizeCloudflareName(value string) string { + return strings.TrimSuffix(value, ".") +} + +func recordSetZoneKey(recordSet *dnsv1alpha1.RecordSet) (string, string) { + namespace := recordSet.Namespace + if recordSet.Spec.ZoneRef.Namespace != nil && *recordSet.Spec.ZoneRef.Namespace != "" { + namespace = *recordSet.Spec.ZoneRef.Namespace + } + return namespace, recordSet.Spec.ZoneRef.Name +} + +func fullRecordNamePatternMatch(pattern, recordName string) (bool, error) { + compiled, err := regexp.Compile(pattern) + if err != nil { + return false, err + } + match := compiled.FindStringIndex(recordName) + return match != nil && match[0] == 0 && match[1] == len(recordName), nil +} + +func int32PtrValue(value *int32) int32 { + if value == nil { + return 0 + } + return *value +} + +func boolPtrValue(value *bool) bool { + return value != nil && *value +} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset_status.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_status.go new file mode 100644 index 0000000..d2994b6 --- /dev/null +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_status.go @@ -0,0 +1,200 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "fmt" + cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "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" + "slices" +) + +func (r *recordSetReconciler) setReady(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, observed []CloudflareDNSRecord) error { + if err := validateCloudflareDNSRecordIDs(observed, "Cloudflare DNS record id"); err != nil { + return r.setProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderInvalidRequest", err.Error()) + } + if err := r.patchCloudflareRecordSetStatus(ctx, recordSet, func(data *cloudflarev1alpha1.CloudflareRecordSetStatusData, state *cloudflarev1alpha1.CloudflareRecordSetState) { + data.Records = cloudflareDNSRecordStatuses(observed) + state.Records = cloudflareDNSRecordStates(observed) + }); err != nil { + return err + } + if err := r.setAccepted(ctx, recordSet, metav1.ConditionTrue, "Accepted", "RecordSet is accepted by Cloudflare policy"); err != nil { + return err + } + return r.setProgrammed(ctx, recordSet, metav1.ConditionTrue, "Programmed", "Cloudflare DNS records match desired state") +} + +func (r *recordSetReconciler) setAccepted(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, status metav1.ConditionStatus, reason, message string) error { + return r.patchStatus(ctx, recordSet, false, func(recordSetStatus *dnsv1alpha1.RecordSetStatus) error { + recordSetStatus.ObservedGeneration = recordSet.Generation + setCondition(&recordSetStatus.Conditions, string(dnsv1alpha1.ConditionAccepted), status, reason, message, recordSet.Generation) + return nil + }) +} + +func (r *recordSetReconciler) setProgrammed(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, status metav1.ConditionStatus, reason, message string) error { + return r.patchStatus(ctx, recordSet, false, func(recordSetStatus *dnsv1alpha1.RecordSetStatus) error { + recordSetStatus.ObservedGeneration = recordSet.Generation + setCondition(&recordSetStatus.Conditions, string(dnsv1alpha1.ConditionProgrammed), status, reason, message, recordSet.Generation) + return nil + }) +} + +func (r *recordSetReconciler) patchStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, markDeletionCompleted bool, mutate func(*dnsv1alpha1.RecordSetStatus) error) error { + zoneNamespace, zoneName := recordSetZoneKey(recordSet) + var unit dnsv1alpha1.ZoneUnit + if err := r.Get(ctx, client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}, &unit); err != nil { + if apierrors.IsNotFound(err) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), zoneName, fmt.Errorf("ZoneUnit was deleted before RecordSet status could be written")) + } + return err + } + itemIndex := slices.IndexFunc(unit.Spec.RecordSets, func(item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return cloudflareZoneUnitRecordSetSourceMatches(&unit, item, recordSet) + }) + if itemIndex < 0 { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), zoneName, fmt.Errorf("ZoneUnit RecordSet source changed before status could be written")) + } + item := unit.Spec.RecordSets[itemIndex] + statusIndex := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name + }) + deletionAlreadyCompleted := false + if statusIndex >= 0 && cloudflareZoneUnitRecordSetStatusMatchesItem(unit.Status.RecordSets[statusIndex], item) { + recordSet.Status = cloudflareRecordSetStatusFromZoneUnitStatus(unit.Status.RecordSets[statusIndex]) + deletionAlreadyCompleted = unit.Status.RecordSets[statusIndex].DeletionCompleted + } else { + recordSet.Status = dnsv1alpha1.RecordSetStatus{} + } + base := recordSet.DeepCopy() + if err := mutate(&recordSet.Status); err != nil { + return err + } + if equality.Semantic.DeepEqual(base.Status, recordSet.Status) && (!markDeletionCompleted || deletionAlreadyCompleted) { + return nil + } + + unitBase := unit.DeepCopy() + next := dnsv1alpha1.ZoneUnitRecordSetStatus{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Status.ObservedGeneration, + Provider: recordSet.Status.Provider, + Conditions: slices.Clone(recordSet.Status.Conditions), + DeletionCompleted: deletionAlreadyCompleted || markDeletionCompleted, + } + if statusIndex >= 0 { + unit.Status.RecordSets[statusIndex] = next + } else { + unit.Status.RecordSets = append(unit.Status.RecordSets, next) + } + unit.Status.ObservedGeneration = unit.Generation + setZoneUnitProgrammedCondition(&unit) + if equality.Semantic.DeepEqual(unitBase.Status, unit.Status) { + return nil + } + return r.Status().Patch(ctx, &unit, client.MergeFromWithOptions(unitBase, client.MergeFromWithOptimisticLock{})) +} + +func setZoneUnitProgrammedCondition(unit *dnsv1alpha1.ZoneUnit) { + status, reason, message := zoneUnitProgrammedCondition(unit) + setCondition(&unit.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), status, reason, message, unit.Generation) +} + +func zoneUnitProgrammedCondition(unit *dnsv1alpha1.ZoneUnit) (metav1.ConditionStatus, string, string) { + if unit.Status.Zone == nil { + return metav1.ConditionUnknown, "Reconciling", "ZoneUnit zone status is not observed" + } + status := metav1.ConditionTrue + reason := "Programmed" + message := "ZoneUnit is programmed" + if condition := meta.FindStatusCondition(unit.Status.Zone.Conditions, string(dnsv1alpha1.ConditionProgrammed)); condition == nil { + status = metav1.ConditionUnknown + reason = "Reconciling" + message = "ZoneUnit zone Programmed condition is not observed" + } else if condition.Status == metav1.ConditionFalse { + return condition.Status, condition.Reason, condition.Message + } else if condition.Status == metav1.ConditionUnknown { + status = metav1.ConditionUnknown + reason = condition.Reason + message = condition.Message + } + for _, item := range unit.Spec.RecordSets { + if !item.IsAllowed() && !item.DeletionRequested { + continue + } + condition := zoneUnitRecordSetProgrammedCondition(unit, item) + if condition == nil { + if status != metav1.ConditionFalse { + status = metav1.ConditionUnknown + reason = "Reconciling" + message = "ZoneUnit record set Programmed condition is not observed" + } + continue + } + if condition.Status == metav1.ConditionFalse { + return condition.Status, condition.Reason, condition.Message + } + if condition.Status == metav1.ConditionUnknown && status != metav1.ConditionFalse { + status = metav1.ConditionUnknown + reason = condition.Reason + message = condition.Message + } + } + return status, reason, message +} + +func zoneUnitRecordSetProgrammedCondition(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitRecordSetSpec) *metav1.Condition { + index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return cloudflareZoneUnitRecordSetStatusMatchesItem(status, item) + }) + if index < 0 { + return nil + } + status := unit.Status.RecordSets[index] + condition := meta.FindStatusCondition(status.Conditions, string(dnsv1alpha1.ConditionProgrammed)) + if status.ObservedGeneration != item.ObservedGeneration || condition == nil || + condition.ObservedGeneration != item.ObservedGeneration { + return nil + } + return condition +} + +func (r *recordSetReconciler) patchCloudflareRecordSetStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*cloudflarev1alpha1.CloudflareRecordSetStatusData, *cloudflarev1alpha1.CloudflareRecordSetState)) error { + return r.patchStatus(ctx, recordSet, false, func(status *dnsv1alpha1.RecordSetStatus) error { + statusData, err := cloudflareRecordSetStatusDataFromProvider(status.Provider) + if err != nil { + return err + } + statusState, err := cloudflareRecordSetStateFromProvider(status.Provider) + if err != nil { + return err + } + mutate(&statusData, &statusState) + rawData, err := json.Marshal(statusData) + if err != nil { + return err + } + var rawState []byte + if len(statusState.Records) > 0 { + rawState, err = json.Marshal(statusState) + if err != nil { + return err + } + } + status.ObservedGeneration = recordSet.Generation + status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: rawData}} + if len(rawState) > 0 { + status.Provider.State = runtime.RawExtension{Raw: rawState} + } + return nil + }) +} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset_test.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_test.go index 76ab5db..10d61c5 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/recordset_test.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset_test.go @@ -3,7 +3,6 @@ package cloudflare import ( "context" "encoding/json" - "fmt" "slices" "strings" "testing" @@ -252,7 +251,8 @@ func syncCloudflareRecordSetStatusToUnit(t *testing.T, ctx context.Context, k8sC next := dnsv1alpha1.ZoneUnitRecordSetStatus{ RecordSetNamespace: recordSet.Namespace, RecordSetName: recordSet.Name, - ObservedGeneration: recordSet.Status.ObservedGeneration, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, Provider: recordSet.Status.Provider, Conditions: slices.Clone(recordSet.Status.Conditions), } @@ -270,6 +270,7 @@ func cloudflareZoneUnitRecordSetSpec(recordSet *dnsv1alpha1.RecordSet) dnsv1alph return dnsv1alpha1.ZoneUnitRecordSetSpec{ RecordSetNamespace: recordSet.Namespace, RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, ObservedGeneration: recordSet.Generation, Name: recordSet.Spec.Name, Type: recordSet.Spec.Type, @@ -338,14 +339,15 @@ func TestCloudflarerecordSetReconcilerPatchesMatchingValue(t *testing.T) { } } -func TestCloudflarerecordSetReconcilerRepairsPartialManagedStatusWhenProviderMatchesDesired(t *testing.T) { +func TestCloudflareRecordSetReconcilerDoesNotAdoptForeignSameTypeRecordsAcrossDesiredUpdates(t *testing.T) { ctx := context.Background() zone := cloudflareProgrammedZone("app", "apps-example-com") recordSet := cloudflareARecordSet("app", "www-a") recordSet.Finalizers = []string{RecordSetFinalizer} + recordSet.Spec.A.Addresses = []string{"192.0.2.10", "192.0.2.11"} ttl := int32(300) proxied := false - first := CloudflareDNSRecord{ + owned := CloudflareDNSRecord{ ID: "023e105f4ecef8ad9ca31a8372d0c010", Type: "A", Name: "www.apps.example.com", @@ -353,7 +355,7 @@ func TestCloudflarerecordSetReconcilerRepairsPartialManagedStatusWhenProviderMat TTL: &ttl, Proxied: &proxied, } - second := CloudflareDNSRecord{ + foreign := CloudflareDNSRecord{ ID: "023e105f4ecef8ad9ca31a8372d0c011", Type: "A", Name: "www.apps.example.com", @@ -361,46 +363,64 @@ func TestCloudflarerecordSetReconcilerRepairsPartialManagedStatusWhenProviderMat TTL: &ttl, Proxied: &proxied, } - rawStatus, err := json.Marshal(cloudflarev1alpha1.CloudflareRecordSetStatusData{ - Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{first}), - }) - if err != nil { - t.Fatalf("marshal status: %v", err) - } - recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: rawStatus}} - k8sClient := fake.NewClientBuilder(). - WithScheme(cloudflareTestScheme(t)). - WithObjects( - cloudflareRecordSetProvider(), - acceptedReadyCloudflareIdentity("platform", "cloudflare-ci"), - acceptedCloudflareZoneClass("platform", "cloudflare-public"), - zone, - recordSet, - cloudflareZoneUnitWithRecordSetItem("app", "apps-example-com", "app", "www-a", "www", dnsv1alpha1.RecordTypeA), - ). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.ZoneClass{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneUnit{}, &cloudflarev1alpha1.CloudflareIdentity{}). - Build() - provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{first, second}} + unit := cloudflareRecordSetLifecycleUnit(recordSet, zone, false) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{Data: cloudflareRaw(t, cloudflarev1alpha1.CloudflareRecordSetStatusData{ + Records: cloudflareDNSRecordStatuses([]CloudflareDNSRecord{owned}), + })}, + }} + k8sClient := cloudflareRecordSetLifecycleClient(t, zone, unit) + provider := &fakeCloudflareRecordSetProvider{records: []CloudflareDNSRecord{owned, foreign}} reconciler := &recordSetReconciler{Client: k8sClient, Provider: provider} - if _, err := reconcileCloudflareRecordSetAndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "www-a"}}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, unit); err != nil { + t.Fatalf("Reconcile returned error when desired records matched the owned and foreign union: %v", err) + } + var afterExpansion dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &afterExpansion); err != nil { + t.Fatalf("ZoneUnit was not found after desired expansion: %v", err) + } + status := cloudflareLifecycleRecordSetStatus(t, &afterExpansion, recordSet) + data, err := cloudflareRecordSetStatusDataFromProvider(status.Provider) + if err != nil { + t.Fatalf("status provider data error after desired expansion: %v", err) + } + if len(data.Records) != 1 || data.Records[0].ID != owned.ID { + t.Fatalf("foreign record was bound to current ownership: %#v", data.Records) + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderConflict") + if len(provider.created) != 0 || len(provider.patched) != 0 || len(provider.deleted) != 0 { + t.Fatalf("provider writes during foreign record conflict = created %#v patched %#v deleted %#v, want none", provider.created, provider.patched, provider.deleted) } - if len(provider.created) != 0 || len(provider.deleted) != 0 { - t.Fatalf("provider writes = created %#v deleted %#v, want none", provider.created, provider.deleted) + recordSet.Generation = 2 + recordSet.Spec.A.Addresses = []string{"192.0.2.10"} + afterExpansion.Spec.RecordSets[0] = cloudflareZoneUnitRecordSetSpec(recordSet) + if err := k8sClient.Update(ctx, &afterExpansion); err != nil { + t.Fatalf("update ZoneUnit for desired contraction: %v", err) } - var got dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "www-a"}, &got); err != nil { - t.Fatalf("RecordSet was not found: %v", err) + if _, err := reconciler.reconcileZoneUnitRecordSets(ctx, &afterExpansion); err != nil { + t.Fatalf("Reconcile returned error after desired contraction: %v", err) } - assertCloudflareCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") - data, err := cloudflareRecordSetStatusData(&got) + var afterContraction dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &afterContraction); err != nil { + t.Fatalf("ZoneUnit was not found after desired contraction: %v", err) + } + status = cloudflareLifecycleRecordSetStatus(t, &afterContraction, recordSet) + data, err = cloudflareRecordSetStatusDataFromProvider(status.Provider) if err != nil { - t.Fatalf("status data error: %v", err) + t.Fatalf("status provider data error after desired contraction: %v", err) } - if len(data.Records) != 2 { - t.Fatalf("status records = %#v, want repaired 2 records", data.Records) + if len(data.Records) != 1 || data.Records[0].ID != owned.ID { + t.Fatalf("foreign record was bound after desired contraction: %#v", data.Records) + } + assertCloudflareCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderConflict") + if len(provider.created) != 0 || len(provider.patched) != 0 || len(provider.deleted) != 0 { + t.Fatalf("provider writes after desired contraction = created %#v patched %#v deleted %#v, want none", provider.created, provider.patched, provider.deleted) } } @@ -822,216 +842,3 @@ func TestCloudflarerecordSetReconcilerRejectsInvalidObservedRecordIDs(t *testing }) } } - -func cloudflareRecordSetProvider() *dnsv1alpha1.Provider { - provider := cloudflareProvider() - provider.Spec.Versions[0].RecordSet.SupportedTypes = []dnsv1alpha1.RecordType{ - dnsv1alpha1.RecordTypeA, - dnsv1alpha1.RecordTypeAAAA, - dnsv1alpha1.RecordTypeTXT, - dnsv1alpha1.RecordTypeCNAME, - dnsv1alpha1.RecordTypeMX, - dnsv1alpha1.RecordTypeCAA, - dnsv1alpha1.RecordTypeNS, - } - return provider -} - -func cloudflareProgrammedZone(namespace, name string) *dnsv1alpha1.Zone { - zone := cloudflareZone(namespace, name) - raw, err := json.Marshal(cloudflarev1alpha1.CloudflareZoneStatusData{ - Zone: cloudflarev1alpha1.CloudflareZoneStatus{ - ID: "023e105f4ecef8ad9ca31a8372d0c353", - Status: "pending", - Type: "full", - }, - }) - if err != nil { - panic(err) - } - zone.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: raw}} - zone.Status.Conditions = []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, - } - return zone -} - -func cloudflareARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - ttl := int32(300) - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: cloudflarev1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeA, - Name: "www", - TTL: &ttl, - A: &dnsv1alpha1.ARecordSet{Addresses: []string{"192.0.2.10", "192.0.2.11"}}, - }, - } -} - -func cloudflareCNAMERecordSet(namespace, name, target string) *dnsv1alpha1.RecordSet { - ttl := int32(300) - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: cloudflarev1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeCNAME, - Name: "www", - TTL: &ttl, - CNAME: &dnsv1alpha1.CNAMERecordSet{Target: target}, - }, - } -} - -func cloudflareTXTRecordSetWithOptions(namespace, name string, options map[string]any) *dnsv1alpha1.RecordSet { - raw, err := json.Marshal(options) - if err != nil { - panic(err) - } - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: cloudflarev1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeTXT, - Name: "txt", - Options: runtime.RawExtension{Raw: raw}, - TXT: &dnsv1alpha1.TXTRecordSet{Values: []string{"hello"}}, - }, - } -} - -func cloudflareZoneUnitWithRecordSetItem(zoneNamespace, zoneName, recordSetNamespace, recordSetName, recordName string, recordType dnsv1alpha1.RecordType) *dnsv1alpha1.ZoneUnit { - return &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{Namespace: zoneNamespace, Name: zoneName}, - Spec: dnsv1alpha1.ZoneUnitSpec{ - Provider: cloudflarev1alpha1.ProviderRef, - Zone: dnsv1alpha1.ZoneUnitZoneSpec{ - Ref: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: zoneName}, - ObservedGeneration: 1, - DomainName: "apps.example.com", - ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: "platform", Name: "cloudflare-public"}, - }, - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ - { - RecordSetNamespace: recordSetNamespace, - RecordSetName: recordSetName, - Name: recordName, - Type: recordType, - }, - }, - }, - } -} - -type fakeCloudflareZoneRecordProvider struct { - *fakeCloudflareZoneProvider - *fakeCloudflareRecordSetProvider -} - -type fakeCloudflareRecordSetProvider struct { - created []CloudflareDNSRecord - patched []CloudflareDNSRecord - deleted []string - batches []CloudflareDNSRecordBatch - batchErr error - records []CloudflareDNSRecord - createIDs []string - recordsByID map[string]CloudflareDNSRecord -} - -func (p *fakeCloudflareRecordSetProvider) ListDNSRecords(_ context.Context, _, name string) ([]CloudflareDNSRecord, error) { - var out []CloudflareDNSRecord - for _, record := range p.records { - if normalizeCloudflareName(record.Name) == normalizeCloudflareName(name) { - out = append(out, record) - } - } - return out, nil -} - -func (p *fakeCloudflareRecordSetProvider) GetDNSRecord(_ context.Context, _, recordID string) (CloudflareDNSRecord, error) { - if p.recordsByID != nil { - if record, ok := p.recordsByID[recordID]; ok { - return record, nil - } - } - for _, record := range p.records { - if record.ID == recordID { - return record, nil - } - } - return CloudflareDNSRecord{}, &identityReasonError{reason: "ExternalResourceNotFound", message: "not found"} -} - -func (p *fakeCloudflareRecordSetProvider) CreateDNSRecord(_ context.Context, _ string, record CloudflareDNSRecord) (CloudflareDNSRecord, error) { - if len(p.createIDs) > len(p.created) { - record.ID = p.createIDs[len(p.created)] - } else { - record.ID = fmt.Sprintf("023e105f4ecef8ad9ca31a8372d0c%03d", len(p.created)) - } - p.created = append(p.created, record) - p.records = append(p.records, record) - return record, nil -} - -func (p *fakeCloudflareRecordSetProvider) PatchDNSRecord(_ context.Context, _ string, recordID string, record CloudflareDNSRecord) (CloudflareDNSRecord, error) { - record.ID = recordID - p.patched = append(p.patched, record) - for index := range p.records { - if p.records[index].ID == recordID { - p.records[index] = record - return record, nil - } - } - p.records = append(p.records, record) - return record, nil -} - -func (p *fakeCloudflareRecordSetProvider) DeleteDNSRecord(_ context.Context, _, recordID string) error { - p.deleted = append(p.deleted, recordID) - p.records = slices.DeleteFunc(p.records, func(record CloudflareDNSRecord) bool { - return record.ID == recordID - }) - return nil -} - -func (p *fakeCloudflareRecordSetProvider) BatchDNSRecords(ctx context.Context, zoneID string, batch CloudflareDNSRecordBatch) (CloudflareDNSRecordBatch, error) { - p.batches = append(p.batches, batch) - if p.batchErr != nil { - return CloudflareDNSRecordBatch{}, p.batchErr - } - out := CloudflareDNSRecordBatch{ - Deletes: slices.Clone(batch.Deletes), - Patches: make([]CloudflareDNSRecord, 0, len(batch.Patches)), - Posts: make([]CloudflareDNSRecord, 0, len(batch.Posts)), - } - for _, record := range batch.Deletes { - if err := p.DeleteDNSRecord(ctx, zoneID, record.ID); err != nil { - return CloudflareDNSRecordBatch{}, err - } - } - for _, record := range batch.Patches { - updated, err := p.PatchDNSRecord(ctx, zoneID, record.ID, record) - if err != nil { - return CloudflareDNSRecordBatch{}, err - } - out.Patches = append(out.Patches, updated) - } - for _, record := range batch.Posts { - created, err := p.CreateDNSRecord(ctx, zoneID, record) - if err != nil { - return CloudflareDNSRecordBatch{}, err - } - out.Posts = append(out.Posts, created) - } - return out, nil -} - -func ptrBool(value bool) *bool { - return &value -} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/status_test.go b/internal/go/providers/cloudflare/controllers/zoneunit/status_test.go index b7dba85..96d28df 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/status_test.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/status_test.go @@ -67,6 +67,7 @@ func TestZoneUnitProgrammedConditionAggregatesCloudflareTargets(t *testing.T) { t.Run(tt.name, func(t *testing.T) { unit := cloudflareZoneUnitWithRecordSetItem("app", "apps-example-com", "app", "www-a", "www", dnsv1alpha1.RecordTypeA) unit.Generation = 7 + unit.Spec.RecordSets[0].ObservedGeneration = 1 unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{ Conditions: []metav1.Condition{ {Type: string(dnsv1alpha1.ConditionProgrammed), Status: tt.zoneStatus, Reason: tt.zoneReason, ObservedGeneration: 1}, @@ -77,6 +78,8 @@ func TestZoneUnitProgrammedConditionAggregatesCloudflareTargets(t *testing.T) { { RecordSetNamespace: "app", RecordSetName: "www-a", + RecordSetUID: unit.Spec.RecordSets[0].RecordSetUID, + ObservedGeneration: unit.Spec.RecordSets[0].ObservedGeneration, Conditions: []metav1.Condition{ {Type: string(dnsv1alpha1.ConditionProgrammed), Status: tt.recordSetStatus, Reason: tt.recordSetReason, ObservedGeneration: 1}, }, @@ -90,3 +93,26 @@ func TestZoneUnitProgrammedConditionAggregatesCloudflareTargets(t *testing.T) { }) } } + +func TestZoneUnitProgrammedConditionIgnoresDifferentRecordSetUID(t *testing.T) { + unit := cloudflareZoneUnitWithRecordSetItem("app", "apps-example-com", "app", "www-a", "www", dnsv1alpha1.RecordTypeA) + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{ + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + }, + } + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: "386cde02-3890-4ee6-a01a-037ed8354b5d", + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + }, + }, + } + + setZoneUnitProgrammedCondition(unit) + + assertCloudflareCondition(t, unit.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling") +} diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go b/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go index 2dee24f..2b2dd5b 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go @@ -558,7 +558,8 @@ func cloudflareZoneUnit(namespace, name string) *dnsv1alpha1.ZoneUnit { Namespace: namespace, Name: name, }, - DomainName: "apps.example.com", + ObservedGeneration: 1, + DomainName: "apps.example.com", ZoneClassRef: dnsv1alpha1.ObjectReference{ Namespace: "platform", Name: "cloudflare-public", diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset.go b/internal/go/providers/route53/controllers/zoneunit/recordset.go index 8526a2d..716fdab 100644 --- a/internal/go/providers/route53/controllers/zoneunit/recordset.go +++ b/internal/go/providers/route53/controllers/zoneunit/recordset.go @@ -3,13 +3,8 @@ package route53 import ( "context" "encoding/json" - "errors" "fmt" - "net/netip" - "regexp" "slices" - "strconv" - "strings" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" @@ -26,15 +21,9 @@ import ( const RecordSetFinalizer = "route53.dns.appthrust.io/recordset-finalizer" -var ( - cnameTargetPattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?|_[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?)(\.([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?|_[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?))*$`) - aliasDNSNamePattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?))*\.$`) - mxExchangePattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?))*$`) - caaTagPattern = regexp.MustCompile(`^[a-z0-9]+$`) -) - type plannedRecordSetChange struct { recordSet *dnsv1alpha1.RecordSet + source *dnsv1alpha1.RecordSet change RecordSetChange } @@ -66,6 +55,7 @@ func (r *ZoneReconciler) reconcileZoneRecordSets(ctx context.Context, provider P batchCost := 0 for index := range recordSets { recordSet := &recordSets[index] + source := recordSet.DeepCopy() change, ok, err := r.planRecordSet(ctx, zone, zoneClass, hostedZone, recordSet, ownership, current) if err != nil { return ctrl.Result{}, err @@ -84,6 +74,7 @@ func (r *ZoneReconciler) reconcileZoneRecordSets(ctx context.Context, provider P batchCost += cost planned = append(planned, plannedRecordSetChange{ recordSet: recordSet, + source: source, change: change, }) } @@ -100,6 +91,10 @@ func (r *ZoneReconciler) reconcileZoneRecordSets(ctx context.Context, provider P changes = append(changes, item.change) } + if err := r.fenceRecordSetChangeDispatch(ctx, zone, planned...); err != nil { + return ctrl.Result{}, err + } + change, err := provider.ChangeRecordSets(ctx, hostedZone.ID, changes) if err != nil { if providerErrorReason(err) == "ProviderInvalidRequest" { @@ -136,6 +131,10 @@ func (r *ZoneReconciler) reconcileZoneRecordSets(ctx context.Context, provider P func (r *ZoneReconciler) reconcileInvalidRecordSetBatch(ctx context.Context, provider Provider, zone *dnsv1alpha1.Zone, hostedZoneID string, planned []plannedRecordSetChange, batchErr error) (ctrl.Result, error) { handled := false for index, item := range planned { + if err := r.fenceRecordSetChangeDispatch(ctx, zone, item); err != nil { + return ctrl.Result{}, err + } + change, err := provider.ChangeRecordSets(ctx, hostedZoneID, []RecordSetChange{item.change}) if err != nil { if statusErr := r.setRecordSetProviderError(ctx, item.recordSet, err); statusErr != nil { @@ -179,6 +178,28 @@ func (r *ZoneReconciler) reconcileInvalidRecordSetBatch(ctx context.Context, pro return r.resultForProviderError(batchErr), nil } +// fenceRecordSetChangeDispatch proves that the API object which authorized a +// provider mutation still owns every planned source. The status patch carries +// a resource-version precondition even when it has no status delta, so a +// cached observation cannot authorize an external mutation. +func (r *ZoneReconciler) fenceRecordSetChangeDispatch(ctx context.Context, zone *dnsv1alpha1.Zone, planned ...plannedRecordSetChange) error { + var unit dnsv1alpha1.ZoneUnit + key := client.ObjectKey{Namespace: zone.Namespace, Name: zone.Name} + if err := r.Get(ctx, key, &unit); err != nil { + if apierrors.IsNotFound(err) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), zone.Name, fmt.Errorf("ZoneUnit was deleted before RecordSet change could be submitted")) + } + return err + } + for _, item := range planned { + if item.source == nil || !zoneUnitRecordSetSourceCurrent(&unit, item.source) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), zone.Name, fmt.Errorf("ZoneUnit RecordSet source changed before provider mutation")) + } + } + base := unit.DeepCopy() + return r.Status().Patch(ctx, &unit, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) +} + func (r *ZoneReconciler) planRecordSet( ctx context.Context, zone *dnsv1alpha1.Zone, @@ -228,7 +249,7 @@ func (r *ZoneReconciler) planRecordSet( } desired := desiredRoute53RecordSet(recordSet, hostedZone.ID, identity.recordName, options) existing, exists := current[identity.key()] - managed := route53RecordSetStatusMatches(statusData, desired) || slices.Contains(recordSet.Finalizers, RecordSetFinalizer) + managed := providerStatusHasPayload(recordSet.Status.Provider) if adopting, err := route53RecordSetAdoptionEnabled(recordSet); err != nil { return RecordSetChange{}, false, r.setRecordSetAccepted(ctx, recordSet, metav1.ConditionFalse, "InvalidAdoption", err.Error()) @@ -266,13 +287,26 @@ func (r *ZoneReconciler) planRecordSetDelete(ctx context.Context, recordSet *dns } if accepted, conflict := ownership.acceptedOwner(recordSet); !accepted { if conflict { - return RecordSetChange{}, false, r.setRecordSetAccepted(ctx, recordSet, metav1.ConditionFalse, "RecordSetConflict", "record identity is owned by another RecordSet") + return RecordSetChange{}, false, r.setRecordSetDeletionConflict(ctx, recordSet) } - return RecordSetChange{}, false, r.removeRecordSetFinalizer(ctx, recordSet) + if existing.Name == "" { + return RecordSetChange{}, false, r.removeRecordSetFinalizer(ctx, recordSet) + } + return RecordSetChange{}, false, r.setRecordSetProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderConflict", "same Route 53 record set exists without current RecordSet ownership") } if existing.Name == "" { return RecordSetChange{}, false, r.removeRecordSetFinalizer(ctx, recordSet) } + adopting, err := route53RecordSetAdoptionEnabled(recordSet) + if err != nil { + return RecordSetChange{}, false, r.setRecordSetAccepted(ctx, recordSet, metav1.ConditionFalse, "InvalidAdoption", err.Error()) + } + if _, err := route53RecordSetStatusData(recordSet); err != nil { + return RecordSetChange{}, false, r.setRecordSetProgrammed(ctx, recordSet, metav1.ConditionFalse, "ReconcileError", err.Error()) + } + if !adopting && !providerStatusHasPayload(recordSet.Status.Provider) { + return RecordSetChange{}, false, r.setRecordSetProgrammed(ctx, recordSet, metav1.ConditionFalse, "ProviderConflict", "same Route 53 record set exists without current RecordSet ownership") + } return RecordSetChange{Action: RecordSetChangeActionDelete, RecordSet: existing}, true, nil } @@ -356,25 +390,16 @@ func (r *ZoneReconciler) refreshPendingRecordSetChange(ctx context.Context, prov for index := range recordSets { if affectedRecordSetIncludes(pending.AffectedRecordSets, &recordSets[index]) { r.recordEvent(&recordSets[index], corev1.EventTypeNormal, "Route53RecordSetChangeInSync", fmt.Sprintf("Route 53 record set change %s is INSYNC for type=%s name=%s", pending.ID, recordSets[index].Spec.Type, recordSets[index].Spec.Name)) - if pending.Operation != "DELETE_BATCH" { - options, optionsErr := route53RecordSetOptions(&recordSets[index]) - if optionsErr != nil { - return ctrl.Result{}, true, optionsErr - } - identity := recordSetIdentity(recordSets[index].Spec.Type, canonicalRecordName(recordSets[index].Spec.Name, zone.Spec.DomainName)) - desired := desiredRoute53RecordSet(&recordSets[index], statusData.HostedZoneID, identity.recordName, options) - if statusErr := r.setRecordSetReady(ctx, &recordSets[index], desired); statusErr != nil { - return ctrl.Result{}, true, statusErr - } - } } } + // INSYNC confirms the submitted batch, not the current desired generation. + // Clear it and use the normal provider list/plan path before attesting Ready. if err := r.patchRoute53ZoneStatus(ctx, zone, func(data *route53v1alpha1.Route53ZoneStatusData) { data.PendingRecordSetChange = nil }); err != nil { return ctrl.Result{}, true, err } - return ctrl.Result{Requeue: true}, true, nil + return ctrl.Result{}, false, nil } func (r *ZoneReconciler) recordSetsForZone(ctx context.Context, zone *dnsv1alpha1.Zone) ([]dnsv1alpha1.RecordSet, error) { @@ -385,17 +410,19 @@ func (r *ZoneReconciler) recordSetsForZone(ctx context.Context, zone *dnsv1alpha } return nil, err } - statusByRef := map[string]dnsv1alpha1.ZoneUnitRecordSetStatus{} + statusByClaim := make(map[string]dnsv1alpha1.ZoneUnitRecordSetStatus, len(unit.Status.RecordSets)) for _, status := range unit.Status.RecordSets { - statusByRef[zoneUnitRecordSetStatusKey(status)] = status + if status.RecordSetUID != "" { + statusByClaim[zoneUnitRecordSetStatusKey(status)] = status + } } recordSets := make([]dnsv1alpha1.RecordSet, 0, len(unit.Spec.RecordSets)) for _, item := range unit.Spec.RecordSets { if !item.IsAllowed() && !item.DeletionRequested { continue } - status := statusByRef[zoneUnitRecordSetItemKey(item)] - if status.DeletionCompleted && !item.DeletionRequested { + status := statusByClaim[zoneUnitRecordSetItemKey(item)] + if !zoneUnitRecordSetStatusMatchesItem(status, item) || (status.DeletionCompleted && !item.DeletionRequested) { status = dnsv1alpha1.ZoneUnitRecordSetStatus{} } recordSets = append(recordSets, route53RecordSetFromZoneUnitItem(&unit, item, status)) @@ -408,6 +435,7 @@ func route53RecordSetFromZoneUnitItem(unit *dnsv1alpha1.ZoneUnit, item dnsv1alph ObjectMeta: metav1.ObjectMeta{ Namespace: item.RecordSetNamespace, Name: item.RecordSetName, + UID: item.RecordSetUID, Generation: item.ObservedGeneration, }, Spec: dnsv1alpha1.RecordSetSpec{ @@ -512,7 +540,7 @@ func (r *ZoneReconciler) ensureRecordSetFinalizer(ctx context.Context, recordSet } func (r *ZoneReconciler) removeRecordSetFinalizer(ctx context.Context, recordSet *dnsv1alpha1.RecordSet) error { - return r.patchRecordSetStatus(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { + return r.patchRecordSetDeletionCompleted(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { status.ObservedGeneration = recordSet.Generation setRecordSetCondition(&status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed", "Route 53 record set deletion is complete", recordSet.Generation) }) @@ -544,6 +572,14 @@ func (r *ZoneReconciler) setRecordSetProviderError(ctx context.Context, recordSe return r.setRecordSetProgrammed(ctx, recordSet, metav1.ConditionFalse, reason, message) } +func (r *ZoneReconciler) setRecordSetDeletionConflict(ctx context.Context, recordSet *dnsv1alpha1.RecordSet) error { + return r.patchRecordSetStatus(ctx, recordSet, func(recordSetStatus *dnsv1alpha1.RecordSetStatus) { + recordSetStatus.ObservedGeneration = recordSet.Generation + setRecordSetCondition(&recordSetStatus.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict", "record identity is owned by another RecordSet", recordSet.Generation) + setRecordSetCondition(&recordSetStatus.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "RecordSetConflict", "record identity is owned by another RecordSet", recordSet.Generation) + }) +} + func (r *ZoneReconciler) setRecordSetAccepted(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, status metav1.ConditionStatus, reason, message string) error { return r.patchRecordSetStatus(ctx, recordSet, func(recordSetStatus *dnsv1alpha1.RecordSetStatus) { recordSetStatus.ObservedGeneration = recordSet.Generation @@ -559,31 +595,66 @@ func (r *ZoneReconciler) setRecordSetProgrammed(ctx context.Context, recordSet * } func (r *ZoneReconciler) patchRecordSetStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*dnsv1alpha1.RecordSetStatus)) error { - base := recordSet.DeepCopy() + return r.patchRecordSetStatusWithDeletionCompletion(ctx, recordSet, false, mutate) +} + +// patchRecordSetDeletionCompleted is reserved for the provider-confirmed +// deletion completion path. +func (r *ZoneReconciler) patchRecordSetDeletionCompleted(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*dnsv1alpha1.RecordSetStatus)) error { + return r.patchRecordSetStatusWithDeletionCompletion(ctx, recordSet, true, mutate) +} + +func (r *ZoneReconciler) patchRecordSetStatusWithDeletionCompletion(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, deletionCompleted bool, mutate func(*dnsv1alpha1.RecordSetStatus)) error { + before := recordSet.DeepCopy() mutate(&recordSet.Status) - if equality.Semantic.DeepEqual(base.Status, recordSet.Status) { - return nil - } namespace, name := recordSetZoneKey(recordSet) var unit dnsv1alpha1.ZoneUnit if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &unit); err != nil { - return client.IgnoreNotFound(err) + if apierrors.IsNotFound(err) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), name, fmt.Errorf("ZoneUnit was deleted before RecordSet status could be written")) + } + return err } + if !zoneUnitRecordSetSourceCurrent(&unit, recordSet) { + return apierrors.NewConflict(dnsv1alpha1.Resource("zoneunits"), name, fmt.Errorf("ZoneUnit RecordSet source changed before status could be written")) + } + if !deletionCompleted && equality.Semantic.DeepEqual(before.Status, recordSet.Status) { + return nil + } + + // RecordSet is synthesized from a prior ZoneUnit observation. Rebase only + // the fields this writer changed onto a receipt for the same claim, so + // condition writes cannot replay stale provider state from another UID. unitBase := unit.DeepCopy() - index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) + index := -1 + for candidateIndex, status := range unit.Status.RecordSets { + if status.RecordSetNamespace != recordSet.Namespace || status.RecordSetName != recordSet.Name { + continue + } + if index < 0 { + index = candidateIndex + } + if status.RecordSetUID != "" && status.RecordSetUID == recordSet.UID { + index = candidateIndex + break + } + } next := dnsv1alpha1.ZoneUnitRecordSetStatus{ RecordSetNamespace: recordSet.Namespace, RecordSetName: recordSet.Name, - ObservedGeneration: recordSet.Status.ObservedGeneration, - Provider: recordSet.Status.Provider, - Conditions: slices.Clone(recordSet.Status.Conditions), + RecordSetUID: recordSet.UID, + } + if index >= 0 && + unit.Status.RecordSets[index].RecordSetUID != "" && + unit.Status.RecordSets[index].RecordSetUID == recordSet.UID { + next = unit.Status.RecordSets[index] } - if recordSet.DeletionTimestamp != nil { - programmed := meta.FindStatusCondition(recordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed)) - next.DeletionCompleted = programmed != nil && programmed.Status == metav1.ConditionTrue + mergeRecordSetStatus(&next, &before.Status, &recordSet.Status) + next.ObservedGeneration = recordSet.Generation + next.RecordSetUID = recordSet.UID + if deletionCompleted { + next.DeletionCompleted = true } if index >= 0 { unit.Status.RecordSets[index] = next @@ -595,1058 +666,80 @@ func (r *ZoneReconciler) patchRecordSetStatus(ctx context.Context, recordSet *dn if equality.Semantic.DeepEqual(unitBase.Status, unit.Status) { return nil } - return client.IgnoreNotFound(r.Status().Patch(ctx, &unit, client.MergeFrom(unitBase))) + // Merge patches replace arrays. The resource-version precondition turns a + // stale recordSets snapshot into a reconcile conflict instead of replacing + // sibling claims. + return r.Status().Patch(ctx, &unit, client.MergeFromWithOptions(unitBase, client.MergeFromWithOptimisticLock{})) } -func (r *ZoneReconciler) patchRoute53RecordSetStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*route53v1alpha1.Route53RecordSetStatusData)) error { - statusData, err := route53RecordSetStatusData(recordSet) - if err != nil { - return err - } - mutate(&statusData) - raw, err := json.Marshal(statusData) - if err != nil { - return err - } - return r.patchRecordSetStatus(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { - status.ObservedGeneration = recordSet.Generation - status.Provider = nil - if statusData.HostedZoneID != "" || statusData.RecordName != "" || statusData.RecordType != "" { - status.Provider = &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: raw}} - } +func zoneUnitRecordSetSourceCurrent(unit *dnsv1alpha1.ZoneUnit, recordSet *dnsv1alpha1.RecordSet) bool { + index := slices.IndexFunc(unit.Spec.RecordSets, func(item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return item.RecordSetNamespace == recordSet.Namespace && item.RecordSetName == recordSet.Name }) -} - -type route53RecordSetIdentity struct { - recordType dnsv1alpha1.RecordType - recordName string -} - -func recordSetIdentity(recordType dnsv1alpha1.RecordType, recordName string) route53RecordSetIdentity { - return route53RecordSetIdentity{ - recordType: recordType, - recordName: normalizeRoute53RecordName(recordName), - } -} - -func (i route53RecordSetIdentity) key() string { - return string(i.recordType) + "\x00" + i.recordName -} - -func indexRecordSets(recordSets []RecordSetResource) map[string]RecordSetResource { - index := make(map[string]RecordSetResource, len(recordSets)) - for _, recordSet := range recordSets { - identity := recordSetIdentity(recordSet.Type, recordSet.Name) - index[identity.key()] = recordSet - } - return index -} - -func desiredRoute53RecordSet(recordSet *dnsv1alpha1.RecordSet, hostedZoneID, recordName string, options route53v1alpha1.Route53RecordSetOptions) RecordSetResource { - desired := RecordSetResource{ - HostedZoneID: normalizeHostedZoneID(hostedZoneID), - Name: normalizeRoute53RecordName(recordName), - Type: recordSet.Spec.Type, - } - if options.Alias != nil { - desired.Alias = options.Alias - return desired - } - ttl := int64(*recordSet.Spec.TTL) - desired.TTL = &ttl - switch recordSet.Spec.Type { - case dnsv1alpha1.RecordTypeA: - desired.Values = slices.Clone(recordSet.Spec.A.Addresses) - case dnsv1alpha1.RecordTypeAAAA: - desired.Values = slices.Clone(recordSet.Spec.AAAA.Addresses) - case dnsv1alpha1.RecordTypeTXT: - for _, value := range recordSet.Spec.TXT.Values { - desired.Values = append(desired.Values, quoteRoute53TXTValue(value)) - } - case dnsv1alpha1.RecordTypeCNAME: - desired.Values = []string{normalizeRoute53RecordName(recordSet.Spec.CNAME.Target)} - case dnsv1alpha1.RecordTypeMX: - for _, record := range recordSet.Spec.MX.Records { - desired.Values = append(desired.Values, formatRoute53MXValue(record)) - } - case dnsv1alpha1.RecordTypeCAA: - for _, record := range recordSet.Spec.CAA.Records { - desired.Values = append(desired.Values, formatRoute53CAAValue(record)) - } - case dnsv1alpha1.RecordTypeNS: - for _, nameServer := range recordSet.Spec.NS.NameServers { - desired.Values = append(desired.Values, normalizeRoute53RecordName(nameServer)) - } - } - slices.Sort(desired.Values) - return desired -} - -func route53RecordSetEqual(a, b RecordSetResource) bool { - if normalizeHostedZoneID(a.HostedZoneID) != normalizeHostedZoneID(b.HostedZoneID) || - normalizeRoute53RecordName(a.Name) != normalizeRoute53RecordName(b.Name) || - a.Type != b.Type { - return false - } - if (a.TTL == nil) != (b.TTL == nil) { + if index < 0 { return false } - if a.TTL != nil && b.TTL != nil && *a.TTL != *b.TTL { + item := unit.Spec.RecordSets[index] + if !item.IsAllowed() && !item.DeletionRequested { return false } - if (a.Alias == nil) != (b.Alias == nil) { - return false - } - if a.Alias != nil && b.Alias != nil { - if normalizeRoute53AliasDNSNameForCompare(a.Alias.DNSName) != normalizeRoute53AliasDNSNameForCompare(b.Alias.DNSName) || - normalizeHostedZoneID(a.Alias.HostedZoneID) != normalizeHostedZoneID(b.Alias.HostedZoneID) || - a.Alias.EvaluateTargetHealth != b.Alias.EvaluateTargetHealth { - return false - } - } - aValues, ok := canonicalRecordSetValues(a.Type, a.Values) - if !ok { + if item.RecordSetUID == "" || + recordSet.UID == "" || + item.RecordSetUID != recordSet.UID || + item.ObservedGeneration != recordSet.Generation || + item.DeletionRequested != !recordSet.DeletionTimestamp.IsZero() { return false } - bValues, ok := canonicalRecordSetValues(b.Type, b.Values) - if !ok { - return false - } - slices.Sort(aValues) - slices.Sort(bValues) - return slices.Equal(aValues, bValues) + expected := route53RecordSetFromZoneUnitItem(unit, item, dnsv1alpha1.ZoneUnitRecordSetStatus{}) + return equality.Semantic.DeepEqual(expected.Spec, recordSet.Spec) } -func normalizeRoute53AliasDNSNameForCompare(name string) string { - normalized := normalizeRoute53RecordName(name) - withoutDualstack := strings.TrimPrefix(normalized, "dualstack.") - if withoutDualstack != normalized && isRoute53ELBAliasDNSName(withoutDualstack) { - return withoutDualstack +func mergeRecordSetStatus(current *dnsv1alpha1.ZoneUnitRecordSetStatus, before, after *dnsv1alpha1.RecordSetStatus) { + if !equality.Semantic.DeepEqual(before.Provider, after.Provider) { + current.Provider = after.Provider } - return normalized -} - -func isRoute53ELBAliasDNSName(name string) bool { - trimmed := strings.TrimSuffix(name, ".") - return strings.HasSuffix(trimmed, ".elb.amazonaws.com") || - strings.Contains(trimmed, ".elb.") && strings.HasSuffix(trimmed, ".amazonaws.com") || - strings.HasSuffix(trimmed, ".elb.amazonaws.com.cn") || - strings.Contains(trimmed, ".elb.") && strings.HasSuffix(trimmed, ".amazonaws.com.cn") + mergeRecordSetConditions(¤t.Conditions, before.Conditions, after.Conditions) } -func route53RecordSetOptions(recordSet *dnsv1alpha1.RecordSet) (route53v1alpha1.Route53RecordSetOptions, error) { - if len(recordSet.Spec.Options.Raw) == 0 { - return route53v1alpha1.Route53RecordSetOptions{}, nil - } - var options route53v1alpha1.Route53RecordSetOptions - if err := json.Unmarshal(recordSet.Spec.Options.Raw, &options); err != nil { - return route53v1alpha1.Route53RecordSetOptions{}, fmt.Errorf("options must match Route 53 RecordSet schema: %w", err) - } - return options, nil -} - -func validateRoute53RecordSetBody(recordSet *dnsv1alpha1.RecordSet, options route53v1alpha1.Route53RecordSetOptions) string { - if options.Alias != nil { - if recordSet.Spec.Type != dnsv1alpha1.RecordTypeA && recordSet.Spec.Type != dnsv1alpha1.RecordTypeAAAA { - return "Route 53 alias supports only A and AAAA record types" - } - if recordSet.Spec.TTL != nil { - return "ttl must not be specified when route53 alias is used" - } - if recordSet.Spec.A != nil && len(recordSet.Spec.A.Addresses) > 0 { - return "a.addresses must not be specified when route53 alias is used" - } - if recordSet.Spec.AAAA != nil && len(recordSet.Spec.AAAA.Addresses) > 0 { - return "aaaa.addresses must not be specified when route53 alias is used" - } - if recordSet.Spec.TXT != nil && len(recordSet.Spec.TXT.Values) > 0 { - return "txt.values must not be specified when route53 alias is used" - } - if recordSet.Spec.CNAME != nil && recordSet.Spec.CNAME.Target != "" { - return "cname.target must not be specified when route53 alias is used" - } - if recordSet.Spec.MX != nil && len(recordSet.Spec.MX.Records) > 0 { - return "mx.records must not be specified when route53 alias is used" - } - if recordSet.Spec.CAA != nil && len(recordSet.Spec.CAA.Records) > 0 { - return "caa.records must not be specified when route53 alias is used" - } - if recordSet.Spec.NS != nil && len(recordSet.Spec.NS.NameServers) > 0 { - return "ns.nameServers must not be specified when route53 alias is used" - } - if options.Alias.DNSName == "" || options.Alias.HostedZoneID == "" { - return "options.alias.dnsName and options.alias.hostedZoneID are required" - } - if err := validateAliasDNSName(options.Alias.DNSName); err != nil { - return "options.alias.dnsName " + err.Error() - } - return "" - } - if recordSet.Spec.TTL == nil { - return "ttl is required for standard records" - } - switch recordSet.Spec.Type { - case dnsv1alpha1.RecordTypeA: - if recordSet.Spec.A == nil || len(recordSet.Spec.A.Addresses) == 0 { - return "a.addresses is required for standard A records" - } - if err := validateRecordSetIPAddresses(recordSet.Spec.A.Addresses, false); err != nil { - return "a.addresses " + err.Error() - } - if recordSet.Spec.AAAA != nil { - return "aaaa must not be specified for standard A records" - } - if recordSet.Spec.TXT != nil { - return "txt must not be specified for standard A records" - } - if recordSet.Spec.CNAME != nil { - return "cname must not be specified for standard A records" - } - if recordSet.Spec.MX != nil { - return "mx must not be specified for standard A records" - } - if recordSet.Spec.CAA != nil { - return "caa must not be specified for standard A records" - } - if recordSet.Spec.NS != nil { - return "ns must not be specified for standard A records" - } - case dnsv1alpha1.RecordTypeAAAA: - if recordSet.Spec.AAAA == nil || len(recordSet.Spec.AAAA.Addresses) == 0 { - return "aaaa.addresses is required for standard AAAA records" - } - if err := validateRecordSetIPAddresses(recordSet.Spec.AAAA.Addresses, true); err != nil { - return "aaaa.addresses " + err.Error() - } - if recordSet.Spec.A != nil { - return "a must not be specified for standard AAAA records" - } - if recordSet.Spec.TXT != nil { - return "txt must not be specified for standard AAAA records" - } - if recordSet.Spec.CNAME != nil { - return "cname must not be specified for standard AAAA records" - } - if recordSet.Spec.MX != nil { - return "mx must not be specified for standard AAAA records" - } - if recordSet.Spec.CAA != nil { - return "caa must not be specified for standard AAAA records" - } - if recordSet.Spec.NS != nil { - return "ns must not be specified for standard AAAA records" - } - case dnsv1alpha1.RecordTypeTXT: - if recordSet.Spec.TXT == nil || len(recordSet.Spec.TXT.Values) == 0 { - return "txt.values is required for standard TXT records" - } - if err := validateRecordSetTXTValues(recordSet.Spec.TXT.Values); err != nil { - return "txt.values " + err.Error() - } - if recordSet.Spec.A != nil { - return "a must not be specified for standard TXT records" - } - if recordSet.Spec.AAAA != nil { - return "aaaa must not be specified for standard TXT records" - } - if recordSet.Spec.CNAME != nil { - return "cname must not be specified for standard TXT records" - } - if recordSet.Spec.MX != nil { - return "mx must not be specified for standard TXT records" - } - if recordSet.Spec.CAA != nil { - return "caa must not be specified for standard TXT records" - } - if recordSet.Spec.NS != nil { - return "ns must not be specified for standard TXT records" - } - case dnsv1alpha1.RecordTypeCNAME: - if recordSet.Spec.CNAME == nil || recordSet.Spec.CNAME.Target == "" { - return "cname.target is required for standard CNAME records" - } - if err := validateCNAMETarget(recordSet.Spec.CNAME.Target); err != nil { - return "cname.target " + err.Error() - } - if recordSet.Spec.A != nil { - return "a must not be specified for standard CNAME records" - } - if recordSet.Spec.AAAA != nil { - return "aaaa must not be specified for standard CNAME records" - } - if recordSet.Spec.TXT != nil { - return "txt must not be specified for standard CNAME records" - } - if recordSet.Spec.MX != nil { - return "mx must not be specified for standard CNAME records" - } - if recordSet.Spec.CAA != nil { - return "caa must not be specified for standard CNAME records" - } - if recordSet.Spec.NS != nil { - return "ns must not be specified for standard CNAME records" - } - case dnsv1alpha1.RecordTypeMX: - if recordSet.Spec.MX == nil || len(recordSet.Spec.MX.Records) == 0 { - return "mx.records is required for standard MX records" - } - if err := validateRecordSetMXRecords(recordSet.Spec.MX.Records); err != nil { - return "mx.records " + err.Error() - } - if recordSet.Spec.A != nil { - return "a must not be specified for standard MX records" - } - if recordSet.Spec.AAAA != nil { - return "aaaa must not be specified for standard MX records" - } - if recordSet.Spec.TXT != nil { - return "txt must not be specified for standard MX records" - } - if recordSet.Spec.CNAME != nil { - return "cname must not be specified for standard MX records" - } - if recordSet.Spec.CAA != nil { - return "caa must not be specified for standard MX records" +func mergeRecordSetConditions(current *[]metav1.Condition, before, after []metav1.Condition) { + for _, beforeCondition := range before { + if meta.FindStatusCondition(after, beforeCondition.Type) == nil { + *current = slices.DeleteFunc(*current, func(condition metav1.Condition) bool { + return condition.Type == beforeCondition.Type + }) } - if recordSet.Spec.NS != nil { - return "ns must not be specified for standard MX records" - } - case dnsv1alpha1.RecordTypeCAA: - if recordSet.Spec.CAA == nil || len(recordSet.Spec.CAA.Records) == 0 { - return "caa.records is required for standard CAA records" - } - if err := validateRecordSetCAARecords(recordSet.Spec.CAA.Records); err != nil { - return "caa.records " + err.Error() - } - if recordSet.Spec.A != nil { - return "a must not be specified for standard CAA records" - } - if recordSet.Spec.AAAA != nil { - return "aaaa must not be specified for standard CAA records" - } - if recordSet.Spec.TXT != nil { - return "txt must not be specified for standard CAA records" - } - if recordSet.Spec.CNAME != nil { - return "cname must not be specified for standard CAA records" - } - if recordSet.Spec.MX != nil { - return "mx must not be specified for standard CAA records" - } - if recordSet.Spec.NS != nil { - return "ns must not be specified for standard CAA records" - } - case dnsv1alpha1.RecordTypeNS: - if recordSet.Spec.Name == "@" { - return "record name @ is not allowed for delegated NS records" - } - if strings.HasPrefix(recordSet.Spec.Name, "*") { - return "wildcard record names are not allowed for delegated NS records" - } - if recordSet.Spec.NS == nil || len(recordSet.Spec.NS.NameServers) == 0 { - return "ns.nameServers is required for standard NS records" - } - if err := validateRecordSetNSNameServers(recordSet.Spec.NS.NameServers); err != nil { - return "ns.nameServers " + err.Error() - } - if recordSet.Spec.A != nil { - return "a must not be specified for standard NS records" - } - if recordSet.Spec.AAAA != nil { - return "aaaa must not be specified for standard NS records" - } - if recordSet.Spec.TXT != nil { - return "txt must not be specified for standard NS records" - } - if recordSet.Spec.CNAME != nil { - return "cname must not be specified for standard NS records" - } - if recordSet.Spec.MX != nil { - return "mx must not be specified for standard NS records" - } - if recordSet.Spec.CAA != nil { - return "caa must not be specified for standard NS records" - } - default: - return "only A, AAAA, TXT, CNAME, MX, CAA, and delegated NS records are supported without a Route 53 alias option" } - return "" -} - -type route53RecordSetAdoption struct { - Enabled bool `json:"enabled"` -} - -func route53RecordSetAdoptionEnabled(recordSet *dnsv1alpha1.RecordSet) (bool, error) { - if len(recordSet.Spec.Adoption.Raw) == 0 { - return false, nil - } - var adoption route53RecordSetAdoption - if err := json.Unmarshal(recordSet.Spec.Adoption.Raw, &adoption); err != nil { - return true, fmt.Errorf("adoption must be an object with enabled: %w", err) - } - if !adoption.Enabled { - return true, errors.New("adoption.enabled must be true") - } - return true, nil -} - -func route53RecordSetManagedResourceMismatch(recordSet *dnsv1alpha1.RecordSet, statusData route53v1alpha1.Route53RecordSetStatusData) (string, bool, error) { - adopting, err := route53RecordSetAdoptionEnabled(recordSet) - if err != nil || !adopting { - return "", false, err - } - statusZoneID := normalizeHostedZoneID(statusData.HostedZoneID) - statusRecordName := normalizeRoute53RecordName(statusData.RecordName) - if statusZoneID == "" || statusRecordName == "" || statusData.RecordType == "" { - return "", false, nil - } - return "", false, nil -} - -func route53RecordSetStatusData(recordSet *dnsv1alpha1.RecordSet) (route53v1alpha1.Route53RecordSetStatusData, error) { - if recordSet.Status.Provider != nil && len(recordSet.Status.Provider.State.Raw) > 0 { - var data route53v1alpha1.Route53RecordSetStatusData - if err := json.Unmarshal(recordSet.Status.Provider.State.Raw, &data); err != nil { - return route53v1alpha1.Route53RecordSetStatusData{}, fmt.Errorf("RecordSet status.provider.state must match Route 53 schema: %w", err) - } - return data, nil - } - if recordSet.Status.Provider != nil && len(recordSet.Status.Provider.Data.Raw) > 0 { - var data route53v1alpha1.Route53RecordSetStatusData - if err := json.Unmarshal(recordSet.Status.Provider.Data.Raw, &data); err != nil { - return route53v1alpha1.Route53RecordSetStatusData{}, fmt.Errorf("RecordSet status.provider.data must match Route 53 schema: %w", err) - } - return data, nil - } - return route53v1alpha1.Route53RecordSetStatusData{}, nil -} - -func providerStatusHasPayload(provider *dnsv1alpha1.ProviderStatus) bool { - return provider != nil && (len(provider.Data.Raw) > 0 || len(provider.State.Raw) > 0) -} - -func setRoute53RecordSetStatusData(data *route53v1alpha1.Route53RecordSetStatusData, recordSet RecordSetResource) { - data.HostedZoneID = normalizeHostedZoneID(recordSet.HostedZoneID) - data.RecordName = normalizeRoute53RecordName(recordSet.Name) - data.RecordType = string(recordSet.Type) -} - -func route53RecordSetStatusMatches(data route53v1alpha1.Route53RecordSetStatusData, recordSet RecordSetResource) bool { - return normalizeHostedZoneID(data.HostedZoneID) == normalizeHostedZoneID(recordSet.HostedZoneID) && - normalizeRoute53RecordName(data.RecordName) == normalizeRoute53RecordName(recordSet.Name) && - data.RecordType == string(recordSet.Type) -} - -func recordSetZoneKey(recordSet *dnsv1alpha1.RecordSet) (string, string) { - namespace := recordSet.Namespace - if recordSet.Spec.ZoneRef.Namespace != nil && *recordSet.Spec.ZoneRef.Namespace != "" { - namespace = *recordSet.Spec.ZoneRef.Namespace - } - return namespace, recordSet.Spec.ZoneRef.Name -} - -type zoneUnitRecordSetOwnership struct { - byRef map[string]dnsv1alpha1.ZoneUnitRecordSetSpec - byIdentity map[string]dnsv1alpha1.ZoneUnitRecordSetSpec -} - -func newZoneUnitRecordSetOwnership(items []dnsv1alpha1.ZoneUnitRecordSetSpec) *zoneUnitRecordSetOwnership { - ownership := &zoneUnitRecordSetOwnership{ - byRef: make(map[string]dnsv1alpha1.ZoneUnitRecordSetSpec, len(items)), - byIdentity: make(map[string]dnsv1alpha1.ZoneUnitRecordSetSpec, len(items)), - } - for _, item := range items { - refKey := recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) - ownership.byRef[refKey] = item - ownership.byIdentity[recordIdentityKey(item.Name, item.Type)] = item - if item.Type == dnsv1alpha1.RecordTypeCNAME { - ownership.byIdentity[cnameExclusionKey(item.Name)] = item - } - } - return ownership -} - -func (o *zoneUnitRecordSetOwnership) acceptedOwner(recordSet *dnsv1alpha1.RecordSet) (bool, bool) { - refKey := recordSetClaimKey(recordSet.Namespace, recordSet.Name) - if item, ok := o.byRef[refKey]; ok { - if item.Name == recordSet.Spec.Name && item.Type == recordSet.Spec.Type { - return true, false + for _, afterCondition := range after { + beforeCondition := meta.FindStatusCondition(before, afterCondition.Type) + if beforeCondition != nil && equality.Semantic.DeepEqual(*beforeCondition, afterCondition) { + continue } - return false, true - } - for _, identityKey := range zoneUnitRecordSetIdentityKeys(recordSet.Spec.Name, recordSet.Spec.Type) { - if owner, ok := o.byIdentity[identityKey]; ok && recordSetClaimKey(owner.RecordSetNamespace, owner.RecordSetName) != refKey { - return false, true + index := slices.IndexFunc(*current, func(condition metav1.Condition) bool { + return condition.Type == afterCondition.Type + }) + if index >= 0 { + (*current)[index] = afterCondition + } else { + *current = append(*current, afterCondition) } } - return false, false -} - -func zoneUnitRecordSetIdentityKeys(recordName string, recordType dnsv1alpha1.RecordType) []string { - keys := []string{recordIdentityKey(recordName, recordType)} - keys = append(keys, cnameExclusionKey(recordName)) - return keys -} - -func recordSetClaimKey(namespace, name string) string { - return namespace + "\x00" + name -} - -func zoneUnitRecordSetItemKey(item dnsv1alpha1.ZoneUnitRecordSetSpec) string { - return recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) -} - -func zoneUnitRecordSetStatusKey(status dnsv1alpha1.ZoneUnitRecordSetStatus) string { - return recordSetClaimKey(status.RecordSetNamespace, status.RecordSetName) -} - -func recordIdentityKey(name string, recordType dnsv1alpha1.RecordType) string { - return name + "\x00" + string(recordType) } -func cnameExclusionKey(name string) string { - return name + "\x00" + string(dnsv1alpha1.RecordTypeCNAME) + "\x00exclusive" -} - -func canonicalRecordName(ownerName, domainName string) string { - switch ownerName { - case "@": - return domainName + "." - case "*": - return "*." + domainName + "." - default: - return ownerName + "." + domainName + "." - } -} - -func providerVersionSupportsType(providerVersion *dnsv1alpha1.ProviderVersion, recordType dnsv1alpha1.RecordType) bool { - return providerVersion != nil && slices.Contains(providerVersion.RecordSet.SupportedTypes, recordType) -} - -func fullRecordNamePatternMatch(pattern, recordName string) (bool, error) { - compiled, err := regexp.Compile(pattern) +func (r *ZoneReconciler) patchRoute53RecordSetStatus(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, mutate func(*route53v1alpha1.Route53RecordSetStatusData)) error { + statusData, err := route53RecordSetStatusData(recordSet) if err != nil { - return false, err - } - match := compiled.FindStringIndex(recordName) - return match != nil && match[0] == 0 && match[1] == len(recordName), nil -} - -func validateRecordSetIPAddresses(values []string, wantIPv6 bool) error { - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - address, err := netip.ParseAddr(value) - if err != nil { - return errors.New("must contain valid IP addresses") - } - if address.Is6() != wantIPv6 { - if wantIPv6 { - return errors.New("must contain only IPv6 addresses") - } - return errors.New("must contain only IPv4 addresses") - } - key := address.String() - if _, ok := seen[key]; ok { - return errors.New("must not contain duplicate IP addresses") - } - seen[key] = struct{}{} - } - return nil -} - -func validateRecordSetTXTValues(values []string) error { - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - valueLen := len([]byte(value)) - if valueLen == 0 { - return errors.New("must not contain empty values") - } - if valueLen > 4000 { - return errors.New("must contain values of 4000 UTF-8 octets or fewer") - } - if !isPrintableASCII(value) { - return errors.New("must contain only printable ASCII characters") - } - if _, ok := seen[value]; ok { - return errors.New("must not contain duplicate values") - } - seen[value] = struct{}{} - } - return nil -} - -func isPrintableASCII(value string) bool { - for index := 0; index < len(value); index++ { - if value[index] < 0x20 || value[index] > 0x7e { - return false - } - } - return true -} - -func validateCNAMETarget(target string) error { - if target == "" { - return errors.New("must not be empty") - } - if len(target) > 253 { - return errors.New("must be 253 octets or fewer") - } - if _, err := netip.ParseAddr(target); err == nil { - return errors.New("must be a DNS name, not an IP address") - } - if !cnameTargetPattern.MatchString(target) { - return errors.New("must be normalized lowercase ASCII without a trailing root dot") - } - return nil -} - -func validateAliasDNSName(name string) error { - if name == "" { - return errors.New("must not be empty") - } - if len(name) > 254 { - return errors.New("must be 254 octets or fewer") - } - if _, err := netip.ParseAddr(strings.TrimSuffix(name, ".")); err == nil { - return errors.New("must be a DNS name, not an IP address") - } - if !aliasDNSNamePattern.MatchString(name) { - return errors.New("must be normalized lowercase ASCII with a trailing root dot") - } - return nil -} - -func validateRecordSetMXRecords(records []dnsv1alpha1.MXRecord) error { - if len(records) == 0 { - return errors.New("is required") - } - seen := make(map[string]struct{}, len(records)) - nullMXIndex := -1 - for index, record := range records { - if record.Preference < 0 || record.Preference > 65535 { - return fmt.Errorf("[%d].preference must be in range 0..65535", index) - } - if record.Exchange == "" { - return fmt.Errorf("[%d].exchange is required", index) - } - if record.Exchange == "." { - nullMXIndex = index - } else if err := validateMXExchange(record.Exchange); err != nil { - return fmt.Errorf("[%d].exchange %w", index, err) - } - key := fmt.Sprintf("%d\x00%s", record.Preference, record.Exchange) - if _, ok := seen[key]; ok { - return errors.New("must not contain duplicate preference and exchange pairs") - } - seen[key] = struct{}{} - } - if nullMXIndex >= 0 { - if len(records) != 1 { - return errors.New("must contain only one record when exchange is \".\"") - } - if records[nullMXIndex].Preference != 0 { - return fmt.Errorf("[%d].preference must be 0 when exchange is \".\"", nullMXIndex) - } - } - return nil -} - -func validateRecordSetCAARecords(records []dnsv1alpha1.CAARecord) error { - if len(records) == 0 { - return errors.New("is required") - } - seen := make(map[string]struct{}, len(records)) - for index, record := range records { - if record.Flags < 0 || record.Flags > 255 { - return fmt.Errorf("[%d].flags must be in range 0..255", index) - } - if record.Tag == "" { - return fmt.Errorf("[%d].tag is required", index) - } - if !caaTagPattern.MatchString(record.Tag) { - return fmt.Errorf("[%d].tag must be lowercase ASCII alphanumeric", index) - } - if record.Value == "" { - return fmt.Errorf("[%d].value is required", index) - } - key := fmt.Sprintf("%d\x00%s\x00%s", record.Flags, record.Tag, record.Value) - if _, ok := seen[key]; ok { - return errors.New("must not contain duplicate flags, tag, and value tuples") - } - seen[key] = struct{}{} - } - return nil -} - -func validateRecordSetNSNameServers(nameServers []string) error { - if len(nameServers) == 0 { - return errors.New("is required") - } - seen := make(map[string]struct{}, len(nameServers)) - for index, nameServer := range nameServers { - if err := validateNSNameServer(nameServer); err != nil { - return fmt.Errorf("[%d] %w", index, err) - } - if _, ok := seen[nameServer]; ok { - return errors.New("must not contain duplicate name servers") - } - seen[nameServer] = struct{}{} - } - return nil -} - -func validateNSNameServer(nameServer string) error { - if nameServer == "" { - return errors.New("must not be empty") - } - if len(nameServer) > 253 { - return errors.New("must be 253 octets or fewer") - } - if _, err := netip.ParseAddr(nameServer); err == nil { - return errors.New("must be a DNS name, not an IP address") - } - if !mxExchangePattern.MatchString(nameServer) { - return errors.New("must be normalized lowercase ASCII without a trailing root dot") - } - return nil -} - -func validateMXExchange(exchange string) error { - if exchange == "" { - return errors.New("must not be empty") - } - if len(exchange) > 253 { - return errors.New("must be 253 octets or fewer") - } - if _, err := netip.ParseAddr(exchange); err == nil { - return errors.New("must be a DNS name, not an IP address") - } - if !mxExchangePattern.MatchString(exchange) { - return errors.New("must be normalized lowercase ASCII without a trailing root dot") - } - return nil -} - -func canonicalRecordSetValues(recordType dnsv1alpha1.RecordType, values []string) ([]string, bool) { - switch recordType { - case dnsv1alpha1.RecordTypeA, dnsv1alpha1.RecordTypeAAAA: - canonical := make([]string, 0, len(values)) - for _, value := range values { - address, err := netip.ParseAddr(value) - if err != nil { - return nil, false - } - if recordType == dnsv1alpha1.RecordTypeA && !address.Is4() { - return nil, false - } - if recordType == dnsv1alpha1.RecordTypeAAAA && !address.Is6() { - return nil, false - } - canonical = append(canonical, address.String()) - } - return canonical, true - case dnsv1alpha1.RecordTypeTXT: - canonical := make([]string, 0, len(values)) - for _, value := range values { - parsed, ok := parseRoute53TXTValue(value) - if !ok { - return nil, false - } - canonical = append(canonical, parsed) - } - return canonical, true - case dnsv1alpha1.RecordTypeCNAME: - canonical := make([]string, 0, len(values)) - for _, value := range values { - canonical = append(canonical, normalizeRoute53RecordName(value)) - } - return canonical, true - case dnsv1alpha1.RecordTypeMX: - canonical := make([]string, 0, len(values)) - for _, value := range values { - record, ok := parseRoute53MXValue(value) - if !ok { - return nil, false - } - canonical = append(canonical, canonicalMXValue(record)) - } - return canonical, true - case dnsv1alpha1.RecordTypeCAA: - canonical := make([]string, 0, len(values)) - for _, value := range values { - record, ok := parseRoute53CAAValue(value) - if !ok { - return nil, false - } - canonical = append(canonical, canonicalCAAValue(record)) - } - return canonical, true - case dnsv1alpha1.RecordTypeNS: - canonical := make([]string, 0, len(values)) - for _, value := range values { - nameServer := strings.TrimSuffix(normalizeRoute53RecordName(value), ".") - if err := validateNSNameServer(nameServer); err != nil { - return nil, false - } - canonical = append(canonical, nameServer) - } - return canonical, true - default: - return slices.Clone(values), true - } -} - -func formatRoute53MXValue(record dnsv1alpha1.MXRecord) string { - if record.Exchange == "." { - return fmt.Sprintf("%d .", record.Preference) - } - return fmt.Sprintf("%d %s", record.Preference, normalizeRoute53RecordName(record.Exchange)) -} - -func canonicalMXValue(record dnsv1alpha1.MXRecord) string { - if record.Exchange == "." { - return fmt.Sprintf("%d .", record.Preference) - } - return fmt.Sprintf("%d %s", record.Preference, strings.TrimSuffix(normalizeRoute53RecordName(record.Exchange), ".")) -} - -func parseRoute53MXValue(value string) (dnsv1alpha1.MXRecord, bool) { - parts := strings.Fields(value) - if len(parts) != 2 { - return dnsv1alpha1.MXRecord{}, false - } - preference, err := strconv.ParseInt(parts[0], 10, 32) - if err != nil || preference < 0 || preference > 65535 { - return dnsv1alpha1.MXRecord{}, false - } - exchange := parts[1] - if exchange == "." { - if preference != 0 { - return dnsv1alpha1.MXRecord{}, false - } - return dnsv1alpha1.MXRecord{Preference: int32(preference), Exchange: "."}, true - } - normalized := strings.TrimSuffix(normalizeRoute53RecordName(exchange), ".") - if err := validateMXExchange(normalized); err != nil { - return dnsv1alpha1.MXRecord{}, false - } - return dnsv1alpha1.MXRecord{Preference: int32(preference), Exchange: normalized}, true -} - -func formatRoute53CAAValue(record dnsv1alpha1.CAARecord) string { - return fmt.Sprintf("%d %s %s", record.Flags, record.Tag, quoteRoute53TXTChunk(record.Value)) -} - -func canonicalCAAValue(record dnsv1alpha1.CAARecord) string { - return fmt.Sprintf("%d %s %s", record.Flags, record.Tag, record.Value) -} - -func parseRoute53CAAValue(value string) (dnsv1alpha1.CAARecord, bool) { - parts := strings.Fields(value) - if len(parts) < 3 { - return dnsv1alpha1.CAARecord{}, false - } - flags, err := strconv.ParseInt(parts[0], 10, 32) - if err != nil || flags < 0 || flags > 255 { - return dnsv1alpha1.CAARecord{}, false - } - tag := parts[1] - if !caaTagPattern.MatchString(tag) { - return dnsv1alpha1.CAARecord{}, false - } - parsedValue, ok := parseRoute53TXTValue(strings.Join(parts[2:], " ")) - if !ok || parsedValue == "" { - return dnsv1alpha1.CAARecord{}, false - } - return dnsv1alpha1.CAARecord{Flags: int32(flags), Tag: tag, Value: parsedValue}, true -} - -func quoteRoute53TXTValue(value string) string { - if value == "" { - return `""` - } - chunks := make([]string, 0, len(value)/255+1) - for len(value) > 0 { - chunkLen := route53TXTChunkLen(value, 255) - chunks = append(chunks, quoteRoute53TXTChunk(value[:chunkLen])) - value = value[chunkLen:] - } - return strings.Join(chunks, " ") -} - -func route53TXTChunkLen(value string, maxBytes int) int { - if len(value) <= maxBytes { - return len(value) - } - last := 0 - for index := range value { - if index > maxBytes { - break - } - last = index - } - if last == 0 { - return maxBytes + return err } - return last -} - -func quoteRoute53TXTChunk(chunk string) string { - var out strings.Builder - out.Grow(len(chunk) + 2) - out.WriteByte('"') - for _, r := range chunk { - if r == '"' || r == '\\' { - out.WriteByte('\\') - } - out.WriteRune(r) + mutate(&statusData) + raw, err := json.Marshal(statusData) + if err != nil { + return err } - out.WriteByte('"') - return out.String() -} - -func parseRoute53TXTValue(value string) (string, bool) { - var out strings.Builder - for index := 0; index < len(value); { - for index < len(value) && (value[index] == ' ' || value[index] == '\t') { - index++ - } - if index >= len(value) { - break - } - if value[index] != '"' { - return "", false - } - index++ - for { - if index >= len(value) { - return "", false - } - if value[index] == '"' { - index++ - break - } - if value[index] == '\\' { - if index+3 < len(value) && isDecimalDigit(value[index+1]) && isDecimalDigit(value[index+2]) && isDecimalDigit(value[index+3]) { - escaped := int(value[index+1]-'0')*100 + int(value[index+2]-'0')*10 + int(value[index+3]-'0') - if escaped > 255 { - return "", false - } - out.WriteByte(byte(escaped)) - index += 4 - continue - } - if index+1 >= len(value) { - return "", false - } - index++ - } - out.WriteByte(value[index]) - index++ + return r.patchRecordSetStatus(ctx, recordSet, func(status *dnsv1alpha1.RecordSetStatus) { + status.ObservedGeneration = recordSet.Generation + status.Provider = nil + if statusData.HostedZoneID != "" || statusData.RecordName != "" || statusData.RecordType != "" { + status.Provider = &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: raw}} } - } - return out.String(), true -} - -func isDecimalDigit(value byte) bool { - return value >= '0' && value <= '9' -} - -func setRecordSetCondition(conditions *[]metav1.Condition, conditionType string, status metav1.ConditionStatus, reason, message string, observedGeneration int64) { - meta.SetStatusCondition(conditions, metav1.Condition{ - Type: conditionType, - Status: status, - Reason: reason, - Message: message, - ObservedGeneration: observedGeneration, }) } - -func route53ChangeCost(change RecordSetChange) int { - cost := 1 - if len(change.RecordSet.Values) > 0 { - cost = len(change.RecordSet.Values) - } - if change.Action == RecordSetChangeActionUpsert { - cost *= 2 - } - return cost -} - -func route53BatchOperation(planned []plannedRecordSetChange) string { - for _, item := range planned { - if item.change.Action == RecordSetChangeActionUpsert { - return "UPSERT_BATCH" - } - } - return "DELETE_BATCH" -} - -func affectedRecordSets(planned []plannedRecordSetChange) []route53v1alpha1.Route53AffectedRecordSet { - affected := make([]route53v1alpha1.Route53AffectedRecordSet, 0, len(planned)) - for _, item := range planned { - affected = append(affected, route53v1alpha1.Route53AffectedRecordSet{ - Namespace: item.recordSet.Namespace, - Name: item.recordSet.Name, - }) - } - return affected -} - -func affectedRecordSetIncludes(affected []route53v1alpha1.Route53AffectedRecordSet, recordSet *dnsv1alpha1.RecordSet) bool { - for _, item := range affected { - if item.Namespace == recordSet.Namespace && item.Name == recordSet.Name { - return true - } - } - return false -} - -func pendingChangeFromChange(change *route53v1alpha1.Route53Change, operation string) *route53v1alpha1.Route53PendingChange { - if change == nil { - return nil - } - return &route53v1alpha1.Route53PendingChange{ - ID: change.ID, - Status: change.Status, - Operation: operation, - SubmittedAt: change.SubmittedAt, - } -} - -func pendingRecordSetChangeFromChange(change *route53v1alpha1.Route53Change, operation string, affected []route53v1alpha1.Route53AffectedRecordSet) *route53v1alpha1.Route53PendingRecordSetChange { - if change == nil { - return nil - } - return &route53v1alpha1.Route53PendingRecordSetChange{ - ID: change.ID, - Status: change.Status, - Operation: operation, - SubmittedAt: change.SubmittedAt, - AffectedRecordSets: affected, - } -} - -func compareString(a, b string) int { - switch { - case a < b: - return -1 - case a > b: - return 1 - default: - return 0 - } -} - -func (r *ZoneReconciler) resultForProviderError(err error) ctrl.Result { - if providerErrorReason(err) == "ProviderUnavailable" { - return ctrl.Result{RequeueAfter: r.changeCheckAfter()} - } - return ctrl.Result{RequeueAfter: r.requeueAfter()} -} - -func providerErrorMessage(err error) string { - _, message := providerErrorCondition(err) - return message -} diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset_changes.go b/internal/go/providers/route53/controllers/zoneunit/recordset_changes.go new file mode 100644 index 0000000..bde3079 --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/recordset_changes.go @@ -0,0 +1,114 @@ +package route53 + +import ( + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" +) + +func setRecordSetCondition(conditions *[]metav1.Condition, conditionType string, status metav1.ConditionStatus, reason, message string, observedGeneration int64) { + meta.SetStatusCondition(conditions, metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: observedGeneration, + }) +} + +func route53ChangeCost(change RecordSetChange) int { + cost := 1 + if len(change.RecordSet.Values) > 0 { + cost = len(change.RecordSet.Values) + } + if change.Action == RecordSetChangeActionUpsert { + cost *= 2 + } + return cost +} + +func route53BatchOperation(planned []plannedRecordSetChange) string { + for _, item := range planned { + if item.change.Action == RecordSetChangeActionUpsert { + return "UPSERT_BATCH" + } + } + return "DELETE_BATCH" +} + +func affectedRecordSets(planned []plannedRecordSetChange) []route53v1alpha1.Route53AffectedRecordSet { + affected := make([]route53v1alpha1.Route53AffectedRecordSet, 0, len(planned)) + for _, item := range planned { + affected = append(affected, route53v1alpha1.Route53AffectedRecordSet{ + Namespace: item.recordSet.Namespace, + Name: item.recordSet.Name, + UID: item.recordSet.UID, + }) + } + return affected +} + +func affectedRecordSetIncludes(affected []route53v1alpha1.Route53AffectedRecordSet, recordSet *dnsv1alpha1.RecordSet) bool { + if recordSet.UID == "" { + return false + } + for _, item := range affected { + if item.UID != "" && + item.UID == recordSet.UID && + item.Namespace == recordSet.Namespace && + item.Name == recordSet.Name { + return true + } + } + return false +} + +func pendingChangeFromChange(change *route53v1alpha1.Route53Change, operation string) *route53v1alpha1.Route53PendingChange { + if change == nil { + return nil + } + return &route53v1alpha1.Route53PendingChange{ + ID: change.ID, + Status: change.Status, + Operation: operation, + SubmittedAt: change.SubmittedAt, + } +} + +func pendingRecordSetChangeFromChange(change *route53v1alpha1.Route53Change, operation string, affected []route53v1alpha1.Route53AffectedRecordSet) *route53v1alpha1.Route53PendingRecordSetChange { + if change == nil { + return nil + } + return &route53v1alpha1.Route53PendingRecordSetChange{ + ID: change.ID, + Status: change.Status, + Operation: operation, + SubmittedAt: change.SubmittedAt, + AffectedRecordSets: affected, + } +} + +func compareString(a, b string) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +func (r *ZoneReconciler) resultForProviderError(err error) ctrl.Result { + if providerErrorReason(err) == "ProviderUnavailable" { + return ctrl.Result{RequeueAfter: r.changeCheckAfter()} + } + return ctrl.Result{RequeueAfter: r.requeueAfter()} +} + +func providerErrorMessage(err error) string { + _, message := providerErrorCondition(err) + return message +} diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset_generation_test.go b/internal/go/providers/route53/controllers/zoneunit/recordset_generation_test.go new file mode 100644 index 0000000..afbfd85 --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/recordset_generation_test.go @@ -0,0 +1,156 @@ +package route53 + +import ( + "context" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestRoute53PendingChangeCannotAttestNewDesiredGeneration(t *testing.T) { + ctx := t.Context() + r, provider, unit, recordSet := route53PendingGenerationFixture(t) + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)} + pending := mustZoneUnitStatusData(t, ctx, r.Client, unit.Namespace, unit.Name).PendingRecordSetChange + if pending == nil { + t.Fatal("initial operation is not pending") + } + if err := r.Get(ctx, request.NamespacedName, unit); err != nil { + t.Fatal(err) + } + unit.Spec.RecordSets[0].ObservedGeneration = 2 + unit.Spec.RecordSets[0].A.Addresses = []string{"192.0.2.99"} + if err := r.Update(ctx, unit); err != nil { + t.Fatal(err) + } + provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync + if _, err := r.Reconcile(ctx, request); err != nil { + t.Fatal(err) + } + if err := r.Get(ctx, request.NamespacedName, unit); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, unit, recordSet.Namespace, recordSet.Name) + condition := meta.FindStatusCondition(status.Conditions, string(dnsv1alpha1.ConditionProgrammed)) + if condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == 2 { + t.Fatal("old pending operation attested the new desired generation before provider convergence") + } + // The same UID retains its provider ownership and converges through an ordinary update. + if _, err := r.Reconcile(ctx, request); err != nil { + t.Fatal(err) + } + pending = mustZoneUnitStatusData(t, ctx, r.Client, unit.Namespace, unit.Name).PendingRecordSetChange + if pending == nil { + t.Fatal("new desired generation did not enter provider convergence") + } + provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync + for range 2 { + if _, err := r.Reconcile(ctx, request); err != nil { + t.Fatal(err) + } + } + observed := provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] + if len(observed.Values) != 1 || observed.Values[0] != "192.0.2.99" { + t.Fatalf("provider did not converge to generation 2: %#v", observed) + } + if err := r.Get(ctx, request.NamespacedName, unit); err != nil { + t.Fatal(err) + } + status = zoneUnitRecordSetStatus(t, unit, recordSet.Namespace, recordSet.Name) + condition = meta.FindStatusCondition(status.Conditions, string(dnsv1alpha1.ConditionProgrammed)) + if condition == nil || condition.Status != metav1.ConditionTrue || condition.ObservedGeneration != 2 { + t.Fatalf("current generation did not recover: %#v", status) + } +} + +func TestRoute53NoOpPreclaimCannotSubmitStaleSource(t *testing.T) { + ctx := t.Context() + r, provider, unit, _ := route53PendingGenerationFixture(t) + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)} + pending := mustZoneUnitStatusData(t, ctx, r.Client, unit.Namespace, unit.Name).PendingRecordSetChange + provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync + for range 2 { + if _, err := r.Reconcile(ctx, request); err != nil { + t.Fatal(err) + } + } + delete(provider.records, recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)) + before := len(provider.upserted) + changed := false + armed := false + baseClient := r.Client + r.Provider = generationChangingListProvider{Provider: provider, beforeList: func() { armed = true }} + r.Client = generationChangingGetClient{Client: baseClient, afterGet: func(obj client.Object) { + observed, ok := obj.(*dnsv1alpha1.ZoneUnit) + if !armed || changed || !ok { + return + } + // Advance source immediately after the first preclaim's read. The following + // no-op preclaim must still observe and reject that newer source. + changed = true + next := observed.DeepCopy() + next.Spec.RecordSets[0].ObservedGeneration = 2 + next.Spec.RecordSets[0].A.Addresses = []string{"192.0.2.99"} + if err := baseClient.Update(ctx, next); err != nil { + t.Fatal(err) + } + }} + _, err := r.Reconcile(ctx, request) + if !changed { + t.Fatal("source interleaving was not exercised") + } + if len(provider.upserted) != before { + t.Fatal("a no-op status preclaim submitted the stale desired generation") + } + if !apierrors.IsConflict(err) { + t.Fatalf("stale source did not fail closed: %v", err) + } +} + +type generationChangingListProvider struct { + Provider + beforeList func() +} + +func (p generationChangingListProvider) ListRecordSets(ctx context.Context, zoneID string) ([]RecordSetResource, error) { + p.beforeList() + return p.Provider.ListRecordSets(ctx, zoneID) +} + +type generationChangingGetClient struct { + client.Client + afterGet func(client.Object) +} + +func (c generationChangingGetClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if err := c.Client.Get(ctx, key, obj, opts...); err != nil { + return err + } + c.afterGet(obj) + return nil +} + +func route53PendingGenerationFixture(t *testing.T) (*ZoneReconciler, *fakeProvider, *dnsv1alpha1.ZoneUnit, *dnsv1alpha1.RecordSet) { + t.Helper() + recordSet := route53ARecordSet("app", "www-a") + recordSet.Generation = 1 + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ID: "Z000001", Name: "apps.example.com", CallerReference: "dns-api:11111111-2222-3333-4444-555555555555"} + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit).WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}).Build() + r := &ZoneReconciler{Client: c, Provider: provider} + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatal(err) + } + return r, provider, unit, recordSet +} diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset_model.go b/internal/go/providers/route53/controllers/zoneunit/recordset_model.go new file mode 100644 index 0000000..4a727dc --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/recordset_model.go @@ -0,0 +1,533 @@ +package route53 + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" +) + +type route53RecordSetIdentity struct { + recordType dnsv1alpha1.RecordType + recordName string +} + +func recordSetIdentity(recordType dnsv1alpha1.RecordType, recordName string) route53RecordSetIdentity { + return route53RecordSetIdentity{ + recordType: recordType, + recordName: normalizeRoute53RecordName(recordName), + } +} + +func (i route53RecordSetIdentity) key() string { + return string(i.recordType) + "\x00" + i.recordName +} + +func indexRecordSets(recordSets []RecordSetResource) map[string]RecordSetResource { + index := make(map[string]RecordSetResource, len(recordSets)) + for _, recordSet := range recordSets { + identity := recordSetIdentity(recordSet.Type, recordSet.Name) + index[identity.key()] = recordSet + } + return index +} + +func desiredRoute53RecordSet(recordSet *dnsv1alpha1.RecordSet, hostedZoneID, recordName string, options route53v1alpha1.Route53RecordSetOptions) RecordSetResource { + desired := RecordSetResource{ + HostedZoneID: normalizeHostedZoneID(hostedZoneID), + Name: normalizeRoute53RecordName(recordName), + Type: recordSet.Spec.Type, + } + if options.Alias != nil { + desired.Alias = options.Alias + return desired + } + ttl := int64(*recordSet.Spec.TTL) + desired.TTL = &ttl + switch recordSet.Spec.Type { + case dnsv1alpha1.RecordTypeA: + desired.Values = slices.Clone(recordSet.Spec.A.Addresses) + case dnsv1alpha1.RecordTypeAAAA: + desired.Values = slices.Clone(recordSet.Spec.AAAA.Addresses) + case dnsv1alpha1.RecordTypeTXT: + for _, value := range recordSet.Spec.TXT.Values { + desired.Values = append(desired.Values, quoteRoute53TXTValue(value)) + } + case dnsv1alpha1.RecordTypeCNAME: + desired.Values = []string{normalizeRoute53RecordName(recordSet.Spec.CNAME.Target)} + case dnsv1alpha1.RecordTypeMX: + for _, record := range recordSet.Spec.MX.Records { + desired.Values = append(desired.Values, formatRoute53MXValue(record)) + } + case dnsv1alpha1.RecordTypeCAA: + for _, record := range recordSet.Spec.CAA.Records { + desired.Values = append(desired.Values, formatRoute53CAAValue(record)) + } + case dnsv1alpha1.RecordTypeNS: + for _, nameServer := range recordSet.Spec.NS.NameServers { + desired.Values = append(desired.Values, normalizeRoute53RecordName(nameServer)) + } + } + slices.Sort(desired.Values) + return desired +} + +func route53RecordSetEqual(a, b RecordSetResource) bool { + if normalizeHostedZoneID(a.HostedZoneID) != normalizeHostedZoneID(b.HostedZoneID) || + normalizeRoute53RecordName(a.Name) != normalizeRoute53RecordName(b.Name) || + a.Type != b.Type { + return false + } + if (a.TTL == nil) != (b.TTL == nil) { + return false + } + if a.TTL != nil && b.TTL != nil && *a.TTL != *b.TTL { + return false + } + if (a.Alias == nil) != (b.Alias == nil) { + return false + } + if a.Alias != nil && b.Alias != nil { + if normalizeRoute53AliasDNSNameForCompare(a.Alias.DNSName) != normalizeRoute53AliasDNSNameForCompare(b.Alias.DNSName) || + normalizeHostedZoneID(a.Alias.HostedZoneID) != normalizeHostedZoneID(b.Alias.HostedZoneID) || + a.Alias.EvaluateTargetHealth != b.Alias.EvaluateTargetHealth { + return false + } + } + aValues, ok := canonicalRecordSetValues(a.Type, a.Values) + if !ok { + return false + } + bValues, ok := canonicalRecordSetValues(b.Type, b.Values) + if !ok { + return false + } + slices.Sort(aValues) + slices.Sort(bValues) + return slices.Equal(aValues, bValues) +} + +func normalizeRoute53AliasDNSNameForCompare(name string) string { + normalized := normalizeRoute53RecordName(name) + withoutDualstack := strings.TrimPrefix(normalized, "dualstack.") + if withoutDualstack != normalized && isRoute53ELBAliasDNSName(withoutDualstack) { + return withoutDualstack + } + return normalized +} + +func isRoute53ELBAliasDNSName(name string) bool { + trimmed := strings.TrimSuffix(name, ".") + return strings.HasSuffix(trimmed, ".elb.amazonaws.com") || + strings.Contains(trimmed, ".elb.") && strings.HasSuffix(trimmed, ".amazonaws.com") || + strings.HasSuffix(trimmed, ".elb.amazonaws.com.cn") || + strings.Contains(trimmed, ".elb.") && strings.HasSuffix(trimmed, ".amazonaws.com.cn") +} + +func route53RecordSetOptions(recordSet *dnsv1alpha1.RecordSet) (route53v1alpha1.Route53RecordSetOptions, error) { + if len(recordSet.Spec.Options.Raw) == 0 { + return route53v1alpha1.Route53RecordSetOptions{}, nil + } + var options route53v1alpha1.Route53RecordSetOptions + if err := json.Unmarshal(recordSet.Spec.Options.Raw, &options); err != nil { + return route53v1alpha1.Route53RecordSetOptions{}, fmt.Errorf("options must match Route 53 RecordSet schema: %w", err) + } + return options, nil +} + +func validateRoute53RecordSetBody(recordSet *dnsv1alpha1.RecordSet, options route53v1alpha1.Route53RecordSetOptions) string { + if options.Alias != nil { + if recordSet.Spec.Type != dnsv1alpha1.RecordTypeA && recordSet.Spec.Type != dnsv1alpha1.RecordTypeAAAA { + return "Route 53 alias supports only A and AAAA record types" + } + if recordSet.Spec.TTL != nil { + return "ttl must not be specified when route53 alias is used" + } + if recordSet.Spec.A != nil && len(recordSet.Spec.A.Addresses) > 0 { + return "a.addresses must not be specified when route53 alias is used" + } + if recordSet.Spec.AAAA != nil && len(recordSet.Spec.AAAA.Addresses) > 0 { + return "aaaa.addresses must not be specified when route53 alias is used" + } + if recordSet.Spec.TXT != nil && len(recordSet.Spec.TXT.Values) > 0 { + return "txt.values must not be specified when route53 alias is used" + } + if recordSet.Spec.CNAME != nil && recordSet.Spec.CNAME.Target != "" { + return "cname.target must not be specified when route53 alias is used" + } + if recordSet.Spec.MX != nil && len(recordSet.Spec.MX.Records) > 0 { + return "mx.records must not be specified when route53 alias is used" + } + if recordSet.Spec.CAA != nil && len(recordSet.Spec.CAA.Records) > 0 { + return "caa.records must not be specified when route53 alias is used" + } + if recordSet.Spec.NS != nil && len(recordSet.Spec.NS.NameServers) > 0 { + return "ns.nameServers must not be specified when route53 alias is used" + } + if options.Alias.DNSName == "" || options.Alias.HostedZoneID == "" { + return "options.alias.dnsName and options.alias.hostedZoneID are required" + } + if err := validateAliasDNSName(options.Alias.DNSName); err != nil { + return "options.alias.dnsName " + err.Error() + } + return "" + } + if recordSet.Spec.TTL == nil { + return "ttl is required for standard records" + } + switch recordSet.Spec.Type { + case dnsv1alpha1.RecordTypeA: + if recordSet.Spec.A == nil || len(recordSet.Spec.A.Addresses) == 0 { + return "a.addresses is required for standard A records" + } + if err := validateRecordSetIPAddresses(recordSet.Spec.A.Addresses, false); err != nil { + return "a.addresses " + err.Error() + } + if recordSet.Spec.AAAA != nil { + return "aaaa must not be specified for standard A records" + } + if recordSet.Spec.TXT != nil { + return "txt must not be specified for standard A records" + } + if recordSet.Spec.CNAME != nil { + return "cname must not be specified for standard A records" + } + if recordSet.Spec.MX != nil { + return "mx must not be specified for standard A records" + } + if recordSet.Spec.CAA != nil { + return "caa must not be specified for standard A records" + } + if recordSet.Spec.NS != nil { + return "ns must not be specified for standard A records" + } + case dnsv1alpha1.RecordTypeAAAA: + if recordSet.Spec.AAAA == nil || len(recordSet.Spec.AAAA.Addresses) == 0 { + return "aaaa.addresses is required for standard AAAA records" + } + if err := validateRecordSetIPAddresses(recordSet.Spec.AAAA.Addresses, true); err != nil { + return "aaaa.addresses " + err.Error() + } + if recordSet.Spec.A != nil { + return "a must not be specified for standard AAAA records" + } + if recordSet.Spec.TXT != nil { + return "txt must not be specified for standard AAAA records" + } + if recordSet.Spec.CNAME != nil { + return "cname must not be specified for standard AAAA records" + } + if recordSet.Spec.MX != nil { + return "mx must not be specified for standard AAAA records" + } + if recordSet.Spec.CAA != nil { + return "caa must not be specified for standard AAAA records" + } + if recordSet.Spec.NS != nil { + return "ns must not be specified for standard AAAA records" + } + case dnsv1alpha1.RecordTypeTXT: + if recordSet.Spec.TXT == nil || len(recordSet.Spec.TXT.Values) == 0 { + return "txt.values is required for standard TXT records" + } + if err := validateRecordSetTXTValues(recordSet.Spec.TXT.Values); err != nil { + return "txt.values " + err.Error() + } + if recordSet.Spec.A != nil { + return "a must not be specified for standard TXT records" + } + if recordSet.Spec.AAAA != nil { + return "aaaa must not be specified for standard TXT records" + } + if recordSet.Spec.CNAME != nil { + return "cname must not be specified for standard TXT records" + } + if recordSet.Spec.MX != nil { + return "mx must not be specified for standard TXT records" + } + if recordSet.Spec.CAA != nil { + return "caa must not be specified for standard TXT records" + } + if recordSet.Spec.NS != nil { + return "ns must not be specified for standard TXT records" + } + case dnsv1alpha1.RecordTypeCNAME: + if recordSet.Spec.CNAME == nil || recordSet.Spec.CNAME.Target == "" { + return "cname.target is required for standard CNAME records" + } + if err := validateCNAMETarget(recordSet.Spec.CNAME.Target); err != nil { + return "cname.target " + err.Error() + } + if recordSet.Spec.A != nil { + return "a must not be specified for standard CNAME records" + } + if recordSet.Spec.AAAA != nil { + return "aaaa must not be specified for standard CNAME records" + } + if recordSet.Spec.TXT != nil { + return "txt must not be specified for standard CNAME records" + } + if recordSet.Spec.MX != nil { + return "mx must not be specified for standard CNAME records" + } + if recordSet.Spec.CAA != nil { + return "caa must not be specified for standard CNAME records" + } + if recordSet.Spec.NS != nil { + return "ns must not be specified for standard CNAME records" + } + case dnsv1alpha1.RecordTypeMX: + if recordSet.Spec.MX == nil || len(recordSet.Spec.MX.Records) == 0 { + return "mx.records is required for standard MX records" + } + if err := validateRecordSetMXRecords(recordSet.Spec.MX.Records); err != nil { + return "mx.records " + err.Error() + } + if recordSet.Spec.A != nil { + return "a must not be specified for standard MX records" + } + if recordSet.Spec.AAAA != nil { + return "aaaa must not be specified for standard MX records" + } + if recordSet.Spec.TXT != nil { + return "txt must not be specified for standard MX records" + } + if recordSet.Spec.CNAME != nil { + return "cname must not be specified for standard MX records" + } + if recordSet.Spec.CAA != nil { + return "caa must not be specified for standard MX records" + } + if recordSet.Spec.NS != nil { + return "ns must not be specified for standard MX records" + } + case dnsv1alpha1.RecordTypeCAA: + if recordSet.Spec.CAA == nil || len(recordSet.Spec.CAA.Records) == 0 { + return "caa.records is required for standard CAA records" + } + if err := validateRecordSetCAARecords(recordSet.Spec.CAA.Records); err != nil { + return "caa.records " + err.Error() + } + if recordSet.Spec.A != nil { + return "a must not be specified for standard CAA records" + } + if recordSet.Spec.AAAA != nil { + return "aaaa must not be specified for standard CAA records" + } + if recordSet.Spec.TXT != nil { + return "txt must not be specified for standard CAA records" + } + if recordSet.Spec.CNAME != nil { + return "cname must not be specified for standard CAA records" + } + if recordSet.Spec.MX != nil { + return "mx must not be specified for standard CAA records" + } + if recordSet.Spec.NS != nil { + return "ns must not be specified for standard CAA records" + } + case dnsv1alpha1.RecordTypeNS: + if recordSet.Spec.Name == "@" { + return "record name @ is not allowed for delegated NS records" + } + if strings.HasPrefix(recordSet.Spec.Name, "*") { + return "wildcard record names are not allowed for delegated NS records" + } + if recordSet.Spec.NS == nil || len(recordSet.Spec.NS.NameServers) == 0 { + return "ns.nameServers is required for standard NS records" + } + if err := validateRecordSetNSNameServers(recordSet.Spec.NS.NameServers); err != nil { + return "ns.nameServers " + err.Error() + } + if recordSet.Spec.A != nil { + return "a must not be specified for standard NS records" + } + if recordSet.Spec.AAAA != nil { + return "aaaa must not be specified for standard NS records" + } + if recordSet.Spec.TXT != nil { + return "txt must not be specified for standard NS records" + } + if recordSet.Spec.CNAME != nil { + return "cname must not be specified for standard NS records" + } + if recordSet.Spec.MX != nil { + return "mx must not be specified for standard NS records" + } + if recordSet.Spec.CAA != nil { + return "caa must not be specified for standard NS records" + } + default: + return "only A, AAAA, TXT, CNAME, MX, CAA, and delegated NS records are supported without a Route 53 alias option" + } + return "" +} + +type route53RecordSetAdoption struct { + Enabled bool `json:"enabled"` +} + +func route53RecordSetAdoptionEnabled(recordSet *dnsv1alpha1.RecordSet) (bool, error) { + if len(recordSet.Spec.Adoption.Raw) == 0 { + return false, nil + } + var adoption route53RecordSetAdoption + if err := json.Unmarshal(recordSet.Spec.Adoption.Raw, &adoption); err != nil { + return true, fmt.Errorf("adoption must be an object with enabled: %w", err) + } + if !adoption.Enabled { + return true, errors.New("adoption.enabled must be true") + } + return true, nil +} + +func route53RecordSetManagedResourceMismatch(recordSet *dnsv1alpha1.RecordSet, statusData route53v1alpha1.Route53RecordSetStatusData) (string, bool, error) { + adopting, err := route53RecordSetAdoptionEnabled(recordSet) + if err != nil || !adopting { + return "", false, err + } + statusZoneID := normalizeHostedZoneID(statusData.HostedZoneID) + statusRecordName := normalizeRoute53RecordName(statusData.RecordName) + if statusZoneID == "" || statusRecordName == "" || statusData.RecordType == "" { + return "", false, nil + } + return "", false, nil +} + +func route53RecordSetStatusData(recordSet *dnsv1alpha1.RecordSet) (route53v1alpha1.Route53RecordSetStatusData, error) { + if recordSet.Status.Provider != nil && len(recordSet.Status.Provider.State.Raw) > 0 { + var data route53v1alpha1.Route53RecordSetStatusData + if err := json.Unmarshal(recordSet.Status.Provider.State.Raw, &data); err != nil { + return route53v1alpha1.Route53RecordSetStatusData{}, fmt.Errorf("RecordSet status.provider.state must match Route 53 schema: %w", err) + } + return data, nil + } + if recordSet.Status.Provider != nil && len(recordSet.Status.Provider.Data.Raw) > 0 { + var data route53v1alpha1.Route53RecordSetStatusData + if err := json.Unmarshal(recordSet.Status.Provider.Data.Raw, &data); err != nil { + return route53v1alpha1.Route53RecordSetStatusData{}, fmt.Errorf("RecordSet status.provider.data must match Route 53 schema: %w", err) + } + return data, nil + } + return route53v1alpha1.Route53RecordSetStatusData{}, nil +} + +func providerStatusHasPayload(provider *dnsv1alpha1.ProviderStatus) bool { + return provider != nil && (len(provider.Data.Raw) > 0 || len(provider.State.Raw) > 0) +} + +func setRoute53RecordSetStatusData(data *route53v1alpha1.Route53RecordSetStatusData, recordSet RecordSetResource) { + data.HostedZoneID = normalizeHostedZoneID(recordSet.HostedZoneID) + data.RecordName = normalizeRoute53RecordName(recordSet.Name) + data.RecordType = string(recordSet.Type) +} + +func recordSetZoneKey(recordSet *dnsv1alpha1.RecordSet) (string, string) { + namespace := recordSet.Namespace + if recordSet.Spec.ZoneRef.Namespace != nil && *recordSet.Spec.ZoneRef.Namespace != "" { + namespace = *recordSet.Spec.ZoneRef.Namespace + } + return namespace, recordSet.Spec.ZoneRef.Name +} + +type zoneUnitRecordSetOwnership struct { + byRef map[string]dnsv1alpha1.ZoneUnitRecordSetSpec + byIdentity map[string]dnsv1alpha1.ZoneUnitRecordSetSpec +} + +func newZoneUnitRecordSetOwnership(items []dnsv1alpha1.ZoneUnitRecordSetSpec) *zoneUnitRecordSetOwnership { + ownership := &zoneUnitRecordSetOwnership{ + byRef: make(map[string]dnsv1alpha1.ZoneUnitRecordSetSpec, len(items)), + byIdentity: make(map[string]dnsv1alpha1.ZoneUnitRecordSetSpec, len(items)), + } + for _, item := range items { + refKey := recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) + ownership.byRef[refKey] = item + ownership.byIdentity[recordIdentityKey(item.Name, item.Type)] = item + if item.Type == dnsv1alpha1.RecordTypeCNAME { + ownership.byIdentity[cnameExclusionKey(item.Name)] = item + } + } + return ownership +} + +func (o *zoneUnitRecordSetOwnership) acceptedOwner(recordSet *dnsv1alpha1.RecordSet) (bool, bool) { + refKey := recordSetClaimKey(recordSet.Namespace, recordSet.Name) + if item, ok := o.byRef[refKey]; ok { + if item.Name != recordSet.Spec.Name || item.Type != recordSet.Spec.Type { + return false, true + } + if item.RecordSetUID == "" || recordSet.UID == "" { + return false, false + } + return item.RecordSetUID == recordSet.UID, item.RecordSetUID != recordSet.UID + } + for _, identityKey := range zoneUnitRecordSetIdentityKeys(recordSet.Spec.Name, recordSet.Spec.Type) { + if owner, ok := o.byIdentity[identityKey]; ok && recordSetClaimKey(owner.RecordSetNamespace, owner.RecordSetName) != refKey { + return false, true + } + } + return false, false +} + +func zoneUnitRecordSetIdentityKeys(recordName string, recordType dnsv1alpha1.RecordType) []string { + keys := []string{recordIdentityKey(recordName, recordType)} + keys = append(keys, cnameExclusionKey(recordName)) + return keys +} + +func recordSetClaimKey(namespace, name string) string { + return namespace + "\x00" + name +} +func zoneUnitRecordSetItemKey(item dnsv1alpha1.ZoneUnitRecordSetSpec) string { + return recordSetClaimKey(item.RecordSetNamespace, item.RecordSetName) + "\x00" + string(item.RecordSetUID) +} + +func zoneUnitRecordSetStatusKey(status dnsv1alpha1.ZoneUnitRecordSetStatus) string { + return recordSetClaimKey(status.RecordSetNamespace, status.RecordSetName) + "\x00" + string(status.RecordSetUID) +} + +func zoneUnitRecordSetStatusMatchesItem(status dnsv1alpha1.ZoneUnitRecordSetStatus, item dnsv1alpha1.ZoneUnitRecordSetSpec) bool { + return item.RecordSetUID != "" && + status.RecordSetUID != "" && + status.RecordSetUID == item.RecordSetUID && + status.RecordSetNamespace == item.RecordSetNamespace && + status.RecordSetName == item.RecordSetName +} + +func recordIdentityKey(name string, recordType dnsv1alpha1.RecordType) string { + return name + "\x00" + string(recordType) +} + +func cnameExclusionKey(name string) string { + return name + "\x00" + string(dnsv1alpha1.RecordTypeCNAME) + "\x00exclusive" +} + +func canonicalRecordName(ownerName, domainName string) string { + switch ownerName { + case "@": + return domainName + "." + case "*": + return "*." + domainName + "." + default: + return ownerName + "." + domainName + "." + } +} + +func providerVersionSupportsType(providerVersion *dnsv1alpha1.ProviderVersion, recordType dnsv1alpha1.RecordType) bool { + return providerVersion != nil && slices.Contains(providerVersion.RecordSet.SupportedTypes, recordType) +} + +func fullRecordNamePatternMatch(pattern, recordName string) (bool, error) { + compiled, err := regexp.Compile(pattern) + if err != nil { + return false, err + } + match := compiled.FindStringIndex(recordName) + return match != nil && match[0] == 0 && match[1] == len(recordName), nil +} diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset_mutation_fence_test.go b/internal/go/providers/route53/controllers/zoneunit/recordset_mutation_fence_test.go new file mode 100644 index 0000000..3f71144 --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/recordset_mutation_fence_test.go @@ -0,0 +1,287 @@ +package route53 + +import ( + "context" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "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" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "testing" +) + +func TestZoneReconcileDispatchFenceRejectsRevokedRecordSet(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + unit, provider := route53RecordSetDispatchFixture(t, recordSet, []string{"192.0.2.20"}) + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + revoked := false + reconciler := &ZoneReconciler{ + Client: k8sClient, + Provider: generationChangingListProvider{ + Provider: provider, + beforeList: func() { + if revoked { + return + } + revoked = true + var current dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), ¤t); err != nil { + t.Fatal(err) + } + current.Spec.RecordSets[0].Allowed = ptr(false) + if err := k8sClient.Update(ctx, ¤t); err != nil { + t.Fatal(err) + } + }, + }, + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); !apierrors.IsConflict(err) { + t.Fatalf("Reconcile error = %v, want revoked source conflict", err) + } + if !revoked { + t.Fatal("record-set allowance was not revoked before dispatch") + } + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 0 { + t.Fatalf("revoked RecordSet authorized provider mutation: upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } +} + +func TestZoneReconcileDispatchFenceRejectsStaleCachedZoneUnit(t *testing.T) { + for _, testCase := range []struct { + name string + deleting bool + }{ + {name: "upsert", deleting: false}, + {name: "delete", deleting: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + if testCase.deleting { + recordSet = deletingRoute53ARecordSet(t) + } + externalValues := []string{"192.0.2.20"} + if testCase.deleting { + externalValues = []string{"192.0.2.10"} + } + unit, provider := route53RecordSetDispatchFixture(t, recordSet, externalValues) + var cached dnsv1alpha1.ZoneUnit + serveCached := false + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if observed, ok := obj.(*dnsv1alpha1.ZoneUnit); ok && serveCached { + *observed = *cached.DeepCopy() + return nil + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &cached); err != nil { + t.Fatal(err) + } + current := cached.DeepCopy() + current.Generation = 2 + current.Spec.RecordSets[0].ObservedGeneration = 2 + current.Spec.RecordSets[0].RecordSetUID = types.UID("11111111-2222-3333-4444-000000000002") + if err := k8sClient.Update(ctx, current); err != nil { + t.Fatal(err) + } + serveCached = true + + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); !apierrors.IsConflict(err) { + t.Fatalf("Reconcile error = %v, want resource-version conflict", err) + } + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 0 { + t.Fatalf("stale cached source authorized provider mutation: upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } + + serveCached = false + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + item := got.Spec.RecordSets[0] + if got.Generation != 2 || item.ObservedGeneration != 2 || item.RecordSetUID != types.UID("11111111-2222-3333-4444-000000000002") { + t.Fatalf("backing ZoneUnit source changed after rejected dispatch: %#v", got.Spec.RecordSets) + } + if testCase.deleting { + if _, found := provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)]; !found { + t.Fatal("stale cached source deleted the external record") + } + } + }) + } +} + +func TestZoneReconcileFallbackDispatchFenceRejectsStaleCachedZoneUnit(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + unit, provider := route53RecordSetDispatchFixture(t, recordSet, []string{"192.0.2.20"}) + var cached dnsv1alpha1.ZoneUnit + serveCached := false + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if observed, ok := obj.(*dnsv1alpha1.ZoneUnit); ok && serveCached { + *observed = *cached.DeepCopy() + return nil + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &cached); err != nil { + t.Fatal(err) + } + batchCalls := 0 + provider.changeRecordSetsErr = func(_ string, _ []RecordSetChange) error { + batchCalls++ + if batchCalls != 1 { + return nil + } + var current dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), ¤t); err != nil { + t.Fatal(err) + } + current.Generation = 2 + current.Spec.RecordSets[0].ObservedGeneration = 2 + current.Spec.RecordSets[0].RecordSetUID = types.UID("11111111-2222-3333-4444-000000000002") + if err := k8sClient.Update(ctx, ¤t); err != nil { + t.Fatal(err) + } + serveCached = true + return &providerReasonError{reason: "ProviderInvalidRequest", message: "batch must be retried singly"} + } + + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); !apierrors.IsConflict(err) { + t.Fatalf("Reconcile error = %v, want fallback source conflict", err) + } + if batchCalls != 1 { + t.Fatalf("provider dispatches = %d, want rejected batch only", batchCalls) + } + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 0 { + t.Fatalf("stale fallback source authorized provider mutation: upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } +} + +func TestZoneReconcileDispatchFenceAllowsFreshRecordSetMutation(t *testing.T) { + for _, testCase := range []struct { + name string + deleting bool + }{ + {name: "upsert", deleting: false}, + {name: "delete", deleting: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + if testCase.deleting { + recordSet = deletingRoute53ARecordSet(t) + } + externalValues := []string{"192.0.2.20"} + if testCase.deleting { + externalValues = []string{"192.0.2.10"} + } + unit, provider := route53RecordSetDispatchFixture(t, recordSet, externalValues) + reconciler := &ZoneReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build(), + Provider: provider, + } + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if testCase.deleting { + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 1 { + t.Fatalf("fresh deletion mutations = upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } + if _, found := provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)]; found { + t.Fatal("fresh deletion did not remove the external record") + } + } else if len(provider.upserted) != 1 || len(provider.deletedRRs) != 0 { + t.Fatalf("fresh update mutations = upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } else if observed := provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)]; len(observed.Values) != 1 || observed.Values[0] != "192.0.2.10" { + t.Fatalf("fresh update did not converge desired external values: %#v", observed) + } + }) + } +} + +func TestZoneReconcileDoesNotPatchStatusWithoutRecordSetMutation(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + unit, provider := route53RecordSetDispatchFixture(t, recordSet, []string{"192.0.2.10"}) + statusPatchAttempts := 0 + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + WithInterceptorFuncs(interceptor.Funcs{ + SubResourcePatch: func(ctx context.Context, c client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if subResourceName == "status" { + statusPatchAttempts++ + } + return c.SubResource(subResourceName).Patch(ctx, obj, patch, opts...) + }, + }). + Build() + + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if statusPatchAttempts != 0 { + t.Fatalf("no-op record-set observation issued %d status patches", statusPatchAttempts) + } + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 0 { + t.Fatalf("no-op record-set observation submitted provider mutation: upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } +} + +func route53RecordSetDispatchFixture(t *testing.T, recordSet *dnsv1alpha1.RecordSet, externalValues []string) (*dnsv1alpha1.ZoneUnit, *fakeProvider) { + t.Helper() + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + route53RecordSetStatusWithState(t, recordSet, "www.apps.example.com."), + } + ttl := int64(300) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + TTL: &ttl, + Values: externalValues, + } + return unit, provider +} diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset_validation.go b/internal/go/providers/route53/controllers/zoneunit/recordset_validation.go new file mode 100644 index 0000000..eebfa51 --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/recordset_validation.go @@ -0,0 +1,440 @@ +package route53 + +import ( + "errors" + "fmt" + "net/netip" + "regexp" + "slices" + "strconv" + "strings" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" +) + +var ( + cnameTargetPattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?|_[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?)(\.([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?|_[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?))*$`) + aliasDNSNamePattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?))*\.$`) + mxExchangePattern = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?))*$`) + caaTagPattern = regexp.MustCompile(`^[a-z0-9]+$`) +) + +func validateRecordSetIPAddresses(values []string, wantIPv6 bool) error { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + address, err := netip.ParseAddr(value) + if err != nil { + return errors.New("must contain valid IP addresses") + } + if address.Is6() != wantIPv6 { + if wantIPv6 { + return errors.New("must contain only IPv6 addresses") + } + return errors.New("must contain only IPv4 addresses") + } + key := address.String() + if _, ok := seen[key]; ok { + return errors.New("must not contain duplicate IP addresses") + } + seen[key] = struct{}{} + } + return nil +} + +func validateRecordSetTXTValues(values []string) error { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + valueLen := len([]byte(value)) + if valueLen == 0 { + return errors.New("must not contain empty values") + } + if valueLen > 4000 { + return errors.New("must contain values of 4000 UTF-8 octets or fewer") + } + if !isPrintableASCII(value) { + return errors.New("must contain only printable ASCII characters") + } + if _, ok := seen[value]; ok { + return errors.New("must not contain duplicate values") + } + seen[value] = struct{}{} + } + return nil +} + +func isPrintableASCII(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] < 0x20 || value[index] > 0x7e { + return false + } + } + return true +} + +func validateCNAMETarget(target string) error { + if target == "" { + return errors.New("must not be empty") + } + if len(target) > 253 { + return errors.New("must be 253 octets or fewer") + } + if _, err := netip.ParseAddr(target); err == nil { + return errors.New("must be a DNS name, not an IP address") + } + if !cnameTargetPattern.MatchString(target) { + return errors.New("must be normalized lowercase ASCII without a trailing root dot") + } + return nil +} + +func validateAliasDNSName(name string) error { + if name == "" { + return errors.New("must not be empty") + } + if len(name) > 254 { + return errors.New("must be 254 octets or fewer") + } + if _, err := netip.ParseAddr(strings.TrimSuffix(name, ".")); err == nil { + return errors.New("must be a DNS name, not an IP address") + } + if !aliasDNSNamePattern.MatchString(name) { + return errors.New("must be normalized lowercase ASCII with a trailing root dot") + } + return nil +} + +func validateRecordSetMXRecords(records []dnsv1alpha1.MXRecord) error { + if len(records) == 0 { + return errors.New("is required") + } + seen := make(map[string]struct{}, len(records)) + nullMXIndex := -1 + for index, record := range records { + if record.Preference < 0 || record.Preference > 65535 { + return fmt.Errorf("[%d].preference must be in range 0..65535", index) + } + if record.Exchange == "" { + return fmt.Errorf("[%d].exchange is required", index) + } + if record.Exchange == "." { + nullMXIndex = index + } else if err := validateMXExchange(record.Exchange); err != nil { + return fmt.Errorf("[%d].exchange %w", index, err) + } + key := fmt.Sprintf("%d\x00%s", record.Preference, record.Exchange) + if _, ok := seen[key]; ok { + return errors.New("must not contain duplicate preference and exchange pairs") + } + seen[key] = struct{}{} + } + if nullMXIndex >= 0 { + if len(records) != 1 { + return errors.New("must contain only one record when exchange is \".\"") + } + if records[nullMXIndex].Preference != 0 { + return fmt.Errorf("[%d].preference must be 0 when exchange is \".\"", nullMXIndex) + } + } + return nil +} + +func validateRecordSetCAARecords(records []dnsv1alpha1.CAARecord) error { + if len(records) == 0 { + return errors.New("is required") + } + seen := make(map[string]struct{}, len(records)) + for index, record := range records { + if record.Flags < 0 || record.Flags > 255 { + return fmt.Errorf("[%d].flags must be in range 0..255", index) + } + if record.Tag == "" { + return fmt.Errorf("[%d].tag is required", index) + } + if !caaTagPattern.MatchString(record.Tag) { + return fmt.Errorf("[%d].tag must be lowercase ASCII alphanumeric", index) + } + if record.Value == "" { + return fmt.Errorf("[%d].value is required", index) + } + key := fmt.Sprintf("%d\x00%s\x00%s", record.Flags, record.Tag, record.Value) + if _, ok := seen[key]; ok { + return errors.New("must not contain duplicate flags, tag, and value tuples") + } + seen[key] = struct{}{} + } + return nil +} + +func validateRecordSetNSNameServers(nameServers []string) error { + if len(nameServers) == 0 { + return errors.New("is required") + } + seen := make(map[string]struct{}, len(nameServers)) + for index, nameServer := range nameServers { + if err := validateNSNameServer(nameServer); err != nil { + return fmt.Errorf("[%d] %w", index, err) + } + if _, ok := seen[nameServer]; ok { + return errors.New("must not contain duplicate name servers") + } + seen[nameServer] = struct{}{} + } + return nil +} + +func validateNSNameServer(nameServer string) error { + if nameServer == "" { + return errors.New("must not be empty") + } + if len(nameServer) > 253 { + return errors.New("must be 253 octets or fewer") + } + if _, err := netip.ParseAddr(nameServer); err == nil { + return errors.New("must be a DNS name, not an IP address") + } + if !mxExchangePattern.MatchString(nameServer) { + return errors.New("must be normalized lowercase ASCII without a trailing root dot") + } + return nil +} + +func validateMXExchange(exchange string) error { + if exchange == "" { + return errors.New("must not be empty") + } + if len(exchange) > 253 { + return errors.New("must be 253 octets or fewer") + } + if _, err := netip.ParseAddr(exchange); err == nil { + return errors.New("must be a DNS name, not an IP address") + } + if !mxExchangePattern.MatchString(exchange) { + return errors.New("must be normalized lowercase ASCII without a trailing root dot") + } + return nil +} + +func canonicalRecordSetValues(recordType dnsv1alpha1.RecordType, values []string) ([]string, bool) { + switch recordType { + case dnsv1alpha1.RecordTypeA, dnsv1alpha1.RecordTypeAAAA: + canonical := make([]string, 0, len(values)) + for _, value := range values { + address, err := netip.ParseAddr(value) + if err != nil { + return nil, false + } + if recordType == dnsv1alpha1.RecordTypeA && !address.Is4() { + return nil, false + } + if recordType == dnsv1alpha1.RecordTypeAAAA && !address.Is6() { + return nil, false + } + canonical = append(canonical, address.String()) + } + return canonical, true + case dnsv1alpha1.RecordTypeTXT: + canonical := make([]string, 0, len(values)) + for _, value := range values { + parsed, ok := parseRoute53TXTValue(value) + if !ok { + return nil, false + } + canonical = append(canonical, parsed) + } + return canonical, true + case dnsv1alpha1.RecordTypeCNAME: + canonical := make([]string, 0, len(values)) + for _, value := range values { + canonical = append(canonical, normalizeRoute53RecordName(value)) + } + return canonical, true + case dnsv1alpha1.RecordTypeMX: + canonical := make([]string, 0, len(values)) + for _, value := range values { + record, ok := parseRoute53MXValue(value) + if !ok { + return nil, false + } + canonical = append(canonical, canonicalMXValue(record)) + } + return canonical, true + case dnsv1alpha1.RecordTypeCAA: + canonical := make([]string, 0, len(values)) + for _, value := range values { + record, ok := parseRoute53CAAValue(value) + if !ok { + return nil, false + } + canonical = append(canonical, canonicalCAAValue(record)) + } + return canonical, true + case dnsv1alpha1.RecordTypeNS: + canonical := make([]string, 0, len(values)) + for _, value := range values { + nameServer := strings.TrimSuffix(normalizeRoute53RecordName(value), ".") + if err := validateNSNameServer(nameServer); err != nil { + return nil, false + } + canonical = append(canonical, nameServer) + } + return canonical, true + default: + return slices.Clone(values), true + } +} + +func formatRoute53MXValue(record dnsv1alpha1.MXRecord) string { + if record.Exchange == "." { + return fmt.Sprintf("%d .", record.Preference) + } + return fmt.Sprintf("%d %s", record.Preference, normalizeRoute53RecordName(record.Exchange)) +} + +func canonicalMXValue(record dnsv1alpha1.MXRecord) string { + if record.Exchange == "." { + return fmt.Sprintf("%d .", record.Preference) + } + return fmt.Sprintf("%d %s", record.Preference, strings.TrimSuffix(normalizeRoute53RecordName(record.Exchange), ".")) +} + +func parseRoute53MXValue(value string) (dnsv1alpha1.MXRecord, bool) { + parts := strings.Fields(value) + if len(parts) != 2 { + return dnsv1alpha1.MXRecord{}, false + } + preference, err := strconv.ParseInt(parts[0], 10, 32) + if err != nil || preference < 0 || preference > 65535 { + return dnsv1alpha1.MXRecord{}, false + } + exchange := parts[1] + if exchange == "." { + if preference != 0 { + return dnsv1alpha1.MXRecord{}, false + } + return dnsv1alpha1.MXRecord{Preference: int32(preference), Exchange: "."}, true + } + normalized := strings.TrimSuffix(normalizeRoute53RecordName(exchange), ".") + if err := validateMXExchange(normalized); err != nil { + return dnsv1alpha1.MXRecord{}, false + } + return dnsv1alpha1.MXRecord{Preference: int32(preference), Exchange: normalized}, true +} + +func formatRoute53CAAValue(record dnsv1alpha1.CAARecord) string { + return fmt.Sprintf("%d %s %s", record.Flags, record.Tag, quoteRoute53TXTChunk(record.Value)) +} + +func canonicalCAAValue(record dnsv1alpha1.CAARecord) string { + return fmt.Sprintf("%d %s %s", record.Flags, record.Tag, record.Value) +} + +func parseRoute53CAAValue(value string) (dnsv1alpha1.CAARecord, bool) { + parts := strings.Fields(value) + if len(parts) < 3 { + return dnsv1alpha1.CAARecord{}, false + } + flags, err := strconv.ParseInt(parts[0], 10, 32) + if err != nil || flags < 0 || flags > 255 { + return dnsv1alpha1.CAARecord{}, false + } + tag := parts[1] + if !caaTagPattern.MatchString(tag) { + return dnsv1alpha1.CAARecord{}, false + } + parsedValue, ok := parseRoute53TXTValue(strings.Join(parts[2:], " ")) + if !ok || parsedValue == "" { + return dnsv1alpha1.CAARecord{}, false + } + return dnsv1alpha1.CAARecord{Flags: int32(flags), Tag: tag, Value: parsedValue}, true +} + +func quoteRoute53TXTValue(value string) string { + if value == "" { + return `""` + } + chunks := make([]string, 0, len(value)/255+1) + for len(value) > 0 { + chunkLen := route53TXTChunkLen(value, 255) + chunks = append(chunks, quoteRoute53TXTChunk(value[:chunkLen])) + value = value[chunkLen:] + } + return strings.Join(chunks, " ") +} + +func route53TXTChunkLen(value string, maxBytes int) int { + if len(value) <= maxBytes { + return len(value) + } + last := 0 + for index := range value { + if index > maxBytes { + break + } + last = index + } + if last == 0 { + return maxBytes + } + return last +} + +func quoteRoute53TXTChunk(chunk string) string { + var out strings.Builder + out.Grow(len(chunk) + 2) + out.WriteByte('"') + for _, r := range chunk { + if r == '"' || r == '\\' { + out.WriteByte('\\') + } + out.WriteRune(r) + } + out.WriteByte('"') + return out.String() +} + +func parseRoute53TXTValue(value string) (string, bool) { + var out strings.Builder + for index := 0; index < len(value); { + for index < len(value) && (value[index] == ' ' || value[index] == '\t') { + index++ + } + if index >= len(value) { + break + } + if value[index] != '"' { + return "", false + } + index++ + for { + if index >= len(value) { + return "", false + } + if value[index] == '"' { + index++ + break + } + if value[index] == '\\' { + if index+3 < len(value) && isDecimalDigit(value[index+1]) && isDecimalDigit(value[index+2]) && isDecimalDigit(value[index+3]) { + escaped := int(value[index+1]-'0')*100 + int(value[index+2]-'0')*10 + int(value[index+3]-'0') + if escaped > 255 { + return "", false + } + out.WriteByte(byte(escaped)) + index += 4 + continue + } + if index+1 >= len(value) { + return "", false + } + index++ + } + out.WriteByte(value[index]) + index++ + } + } + return out.String(), true +} + +func isDecimalDigit(value byte) bool { + return value >= '0' && value <= '9' +} diff --git a/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go b/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go index 93ee37d..709dab9 100644 --- a/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go @@ -1,15 +1,19 @@ package route53 import ( + "bytes" "context" "encoding/json" + "errors" "testing" zoneunitcontroller "github.com/appthrust/dns-api/internal/go/core/controllers/zoneunit" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -156,3 +160,642 @@ func TestUnrelatedZoneStatusUpdatePreservesCurrentProgrammedState(t *testing.T) assertCondition(t, unit.Status.Zone.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") assertCondition(t, unit.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") } + +type recordSetListErrorProvider struct { + Provider + err error +} + +func (p recordSetListErrorProvider) ListRecordSets(context.Context, string) ([]RecordSetResource, error) { + return nil, p.err +} + +func TestZoneReconcileFailsClosedForStaleRecordSetStatusWrites(t *testing.T) { + ctx := t.Context() + recordA := route53ARecordSet("app", "www-a") + recordB := route53ARecordSet("app", "api-a") + recordB.Spec.Name = "api" + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + route53ZoneUnitRecordSetSpec(recordA), + route53ZoneUnitRecordSetSpec(recordB), + } + setRoute53ReadyZoneUnitStatus(t, unit) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + route53RecordSetStatusWithState(t, recordA, "www.apps.example.com."), + route53RecordSetStatusWithState(t, recordB, "api.apps.example.com."), + } + + var cached dnsv1alpha1.ZoneUnit + cacheLag := false + zoneUnitReads := 0 + recordSetStatusWrites := 0 + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if observed, ok := obj.(*dnsv1alpha1.ZoneUnit); ok { + zoneUnitReads++ + if zoneUnitReads == 2 || cacheLag { + *observed = *cached.DeepCopy() + return nil + } + } + return c.Get(ctx, key, obj, opts...) + }, + SubResourcePatch: func(ctx context.Context, c client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + data, err := patch.Data(obj) + if err != nil { + return err + } + if err := c.SubResource(subResourceName).Patch(ctx, obj, patch, opts...); err != nil { + return err + } + if subResourceName == "status" && bytes.Contains(data, []byte(`"recordSets"`)) { + recordSetStatusWrites++ + if recordSetStatusWrites == 1 { + cacheLag = true + } + } + return nil + }, + }). + Build() + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &cached); err != nil { + t.Fatal(err) + } + for index := range cached.Status.RecordSets { + cached.Status.RecordSets[index].Provider = nil + } + zoneUnitReads = 0 + + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + reconciler := &ZoneReconciler{ + Client: k8sClient, + Provider: recordSetListErrorProvider{ + Provider: provider, + err: errors.New("Route 53 list record sets unavailable"), + }, + } + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}) + if !apierrors.IsConflict(err) { + t.Fatalf("Reconcile error = %v, want stale status conflict", err) + } + if !cacheLag { + t.Fatal("record-set status writer did not observe delayed cache data") + } + + cacheLag = false + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + for _, recordSet := range []struct { + name string + recordName string + }{ + {name: "www-a", recordName: "www.apps.example.com."}, + {name: "api-a", recordName: "api.apps.example.com."}, + } { + data := mustZoneUnitRecordSetStatusState(t, ctx, k8sClient, unit.Namespace, unit.Name, unit.Namespace, recordSet.name) + if data.HostedZoneID != "Z000001" || data.RecordName != recordSet.recordName || data.RecordType != string(dnsv1alpha1.RecordTypeA) { + t.Fatalf("%s provider state = %#v, want retained Route 53 record identity", recordSet.name, data) + } + } +} + +func TestZoneReconcileDoesNotCompleteReplacedDeletingRecordSet(t *testing.T) { + ctx := t.Context() + recordSet := deletingRoute53ARecordSet(t) + recordSet.Finalizers = []string{"dns.appthrust.io/recordset-finalizer"} + cachedItem := route53ZoneUnitRecordSetSpec(recordSet) + replacementItem := cachedItem + replacementItem.Type = dnsv1alpha1.RecordTypeCNAME + replacementItem.A = nil + replacementItem.CNAME = &dnsv1alpha1.CNAMERecordSet{Target: "replacement.apps.example.com"} + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{replacementItem} + setRoute53ReadyZoneUnitStatus(t, unit) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + route53RecordSetStatusWithState(t, recordSet, "www.apps.example.com."), + } + + var cached dnsv1alpha1.ZoneUnit + useCachedSource := false + zoneUnitReads := 0 + objects := route53RecordSetObjects(t, recordSet) + for index, object := range objects { + if _, ok := object.(*dnsv1alpha1.ZoneUnit); ok { + objects[index] = unit + } + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.ZoneUnit{}, &dnsv1alpha1.RecordSet{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if observed, ok := obj.(*dnsv1alpha1.ZoneUnit); ok && useCachedSource { + zoneUnitReads++ + if zoneUnitReads <= 2 { + *observed = *cached.DeepCopy() + return nil + } + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &cached); err != nil { + t.Fatal(err) + } + cached.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{cachedItem} + useCachedSource = true + + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + ttl := int64(300) + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + TTL: &ttl, + Values: []string{"192.0.2.10"}, + } + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); !apierrors.IsConflict(err) { + t.Fatalf("Reconcile error = %v, want conflict for replaced deleting RecordSet source", err) + } + if _, found := provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)]; !found { + t.Fatal("provider deleted the external record for a replaced deleting source") + } + if len(provider.deletedRRs) != 0 { + t.Fatalf("provider delete requests = %#v, want none", provider.deletedRRs) + } + + useCachedSource = false + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &gotUnit); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &gotUnit, recordSet.Namespace, recordSet.Name) + if status.DeletionCompleted { + t.Fatalf("replaced deleting RecordSet was marked complete without a provider DELETE: %#v", status) + } + if _, err := (&zoneunitcontroller.ZoneUnitCompositionReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("core composition Reconcile returned error: %v", err) + } + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("deleting RecordSet was removed before provider DELETE: %v", err) + } + if !hasRecordSetFinalizer(&gotRecordSet, "dns.appthrust.io/recordset-finalizer") { + t.Fatalf("RecordSet finalizers = %#v, want core deletion finalizer retained", gotRecordSet.Finalizers) + } +} + +func TestRecordSetDeleteConflictDoesNotCompleteDeletion(t *testing.T) { + ctx := t.Context() + recordSet := deletingRoute53ARecordSet(t) + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + item := route53ZoneUnitRecordSetSpec(recordSet) + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{item} + setRoute53ReadyZoneUnitStatus(t, unit) + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + route53RecordSetStatusWithState(t, recordSet, "www.apps.example.com."), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient} + source := route53RecordSetFromZoneUnitItem(unit, item, unit.Status.RecordSets[0]) + conflictingItem := item + conflictingItem.Type = dnsv1alpha1.RecordTypeCNAME + if _, planned, err := reconciler.planRecordSetDelete(ctx, &source, newZoneUnitRecordSetOwnership([]dnsv1alpha1.ZoneUnitRecordSetSpec{conflictingItem}), RecordSetResource{}); err != nil { + t.Fatal(err) + } else if planned { + t.Fatal("delete was planned despite conflicting owner") + } + + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &got, recordSet.Namespace, recordSet.Name) + if status.DeletionCompleted { + t.Fatalf("blocked delete completed without external deletion: %#v", status) + } + assertCondition(t, status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") + assertCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "RecordSetConflict") +} + +func TestZoneReconcileBindsRecordSetReceiptAndPendingChangeToCurrentUID(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + recordSet.UID = types.UID("11111111-2222-3333-4444-000000000001") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %#v, want one fresh claim", provider.upserted) + } + + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &got, recordSet.Namespace, recordSet.Name) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("recordSet status UID = %q, want %q", status.RecordSetUID, recordSet.UID) + } + if status.Provider == nil || len(status.Provider.State.Raw) == 0 { + t.Fatalf("recordSet provider state = %#v, want current receipt", status.Provider) + } + pending := mustZoneUnitStatusData(t, ctx, k8sClient, unit.Namespace, unit.Name).PendingRecordSetChange + if pending == nil || len(pending.AffectedRecordSets) != 1 { + t.Fatalf("pending record set change = %#v, want one affected current claim", pending) + } + if affected := pending.AffectedRecordSets[0]; affected.Namespace != recordSet.Namespace || affected.Name != recordSet.Name || affected.UID != recordSet.UID { + t.Fatalf("pending affected record set = %#v, want current claim identity", affected) + } +} + +func TestZoneReconcileDoesNotUseNoncurrentRecordSetReceipt(t *testing.T) { + for _, priorUID := range []types.UID{"", "11111111-2222-3333-4444-000000000002"} { + t.Run(string(priorUID), func(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + recordSet.UID = types.UID("11111111-2222-3333-4444-000000000001") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + legacy := route53RecordSetStatusWithState(t, recordSet, "www.apps.example.com.") + legacy.RecordSetUID = priorUID + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{legacy} + ttl := int64(300) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + TTL: &ttl, + Values: []string{"192.0.2.10"}, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 0 { + t.Fatalf("noncurrent receipt authorized a provider mutation: upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } + + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &got, recordSet.Namespace, recordSet.Name) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("recordSet status UID = %q, want current %q", status.RecordSetUID, recordSet.UID) + } + if status.Provider != nil { + t.Fatalf("legacy provider receipt was rebound to current claim: %#v", status.Provider) + } + assertCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderConflict") + }) + } +} + +func TestZoneReconcileDoesNotAttestCurrentClaimFromNoncurrentPendingChange(t *testing.T) { + for _, priorUID := range []types.UID{"", "11111111-2222-3333-4444-000000000002"} { + t.Run(string(priorUID), func(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + recordSet.UID = types.UID("11111111-2222-3333-4444-000000000001") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + state, err := json.Marshal(route53v1alpha1.Route53ZoneStatusData{ + HostedZoneID: "Z000001", + PendingRecordSetChange: &route53v1alpha1.Route53PendingRecordSetChange{ + ID: "/change/predecessor", + Status: route53v1alpha1.Route53ChangeStatusPending, + Operation: "UPSERT_BATCH", + AffectedRecordSets: []route53v1alpha1.Route53AffectedRecordSet{{ + Namespace: recordSet.Namespace, + Name: recordSet.Name, + UID: priorUID, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + unit.Status.Provider = &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: state}} + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + provider.changes["/change/predecessor"] = &route53v1alpha1.Route53Change{ + ID: "/change/predecessor", + Status: route53v1alpha1.Route53ChangeStatusInSync, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)} + + if _, err := reconciler.Reconcile(ctx, request); err != nil { + t.Fatalf("pending completion Reconcile returned error: %v", err) + } + if len(provider.upserted) != 1 { + t.Fatalf("current claim was not submitted after predecessor pending cleared: %#v", provider.upserted) + } + var afterPending dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &afterPending); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &afterPending, recordSet.Namespace, recordSet.Name) + if status.RecordSetUID != recordSet.UID { + t.Fatalf("recordSet status UID = %q, want current %q", status.RecordSetUID, recordSet.UID) + } + assertCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") + if data, err := route53ZoneUnitStatusData(&afterPending); err != nil { + t.Fatal(err) + } else if data.PendingRecordSetChange == nil || + data.PendingRecordSetChange.ID == "/change/predecessor" || + data.PendingRecordSetChange.Status != route53v1alpha1.Route53ChangeStatusPending { + t.Fatalf("fresh current change was not pending after predecessor INSYNC: %#v", data.PendingRecordSetChange) + } + }) + } +} + +func TestZoneReconcileDoesNotDeleteExternalRecordFromNoncurrentReceipt(t *testing.T) { + ctx := t.Context() + recordSet := deletingRoute53ARecordSet(t) + recordSet.UID = types.UID("11111111-2222-3333-4444-000000000001") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + legacy := route53RecordSetStatusWithState(t, recordSet, "www.apps.example.com.") + legacy.RecordSetUID = types.UID("11111111-2222-3333-4444-000000000002") + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{legacy} + ttl := int64(300) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + TTL: &ttl, + Values: []string{"192.0.2.10"}, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.deletedRRs) != 0 { + t.Fatalf("noncurrent receipt authorized external delete: %#v", provider.deletedRRs) + } + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &got, recordSet.Namespace, recordSet.Name) + if status.DeletionCompleted { + t.Fatalf("noncurrent receipt completed deleting claim: %#v", status) + } + if status.RecordSetUID != recordSet.UID || status.Provider != nil { + t.Fatalf("noncurrent receipt was preserved as current provider state: %#v", status) + } + assertCondition(t, status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderConflict") +} + +func TestZoneReconcileCompletesCurrentDeletionAfterFreshExternalAbsence(t *testing.T) { + ctx := t.Context() + recordSet := deletingRoute53ARecordSet(t) + recordSet.UID = types.UID("11111111-2222-3333-4444-000000000001") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + setRoute53ReadyZoneUnitStatus(t, unit) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.deletedRRs) != 0 { + t.Fatalf("external absence issued a delete: %#v", provider.deletedRRs) + } + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &got, recordSet.Namespace, recordSet.Name) + if !status.DeletionCompleted || status.RecordSetUID != recordSet.UID { + t.Fatalf("fresh external absence did not complete current claim deletion: %#v", status) + } +} + +func TestZoneReconcileRejectsMissingRecordSetUIDBeforeProviderMutation(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + item := route53ZoneUnitRecordSetSpec(recordSet) + item.RecordSetUID = "" + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Generation = 1 + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{item} + setRoute53ReadyZoneUnitStatus(t, unit) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), route53ZoneClass("app", "route53-public", nil), acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)}); !apierrors.IsConflict(err) { + t.Fatalf("Reconcile error = %v, want missing-UID source conflict", err) + } + if len(provider.upserted) != 0 || len(provider.deletedRRs) != 0 { + t.Fatalf("missing UID authorized a provider mutation: upserts=%#v deletes=%#v", provider.upserted, provider.deletedRRs) + } +} + +func TestRecordSetStatusWriterRejectsReplacedUIDSource(t *testing.T) { + ctx := t.Context() + recordSet := route53ARecordSet("app", "www") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + route53RecordSetStatusWithState(t, recordSet, "www.apps.example.com."), + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(unit). + WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient} + stale := route53RecordSetFromZoneUnitItem(unit, unit.Spec.RecordSets[0], unit.Status.RecordSets[0]) + stale.UID = types.UID("11111111-2222-3333-4444-000000000002") + + if err := reconciler.setRecordSetProgrammed(ctx, &stale, metav1.ConditionFalse, "ProviderChangePending", "stale source"); !apierrors.IsConflict(err) { + t.Fatalf("status writer error = %v, want replaced-UID source conflict", err) + } + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), &got); err != nil { + t.Fatal(err) + } + status := zoneUnitRecordSetStatus(t, &got, recordSet.Namespace, recordSet.Name) + if status.RecordSetUID != recordSet.UID || status.Provider == nil { + t.Fatalf("replaced UID changed stored current receipt: %#v", status) + } +} + +func setRoute53ReadyZoneUnitStatus(t *testing.T, unit *dnsv1alpha1.ZoneUnit) { + t.Helper() + state, err := json.Marshal(route53v1alpha1.Route53ZoneStatusData{ + HostedZoneID: "Z000001", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + }) + if err != nil { + t.Fatal(err) + } + public, err := json.Marshal(route53v1alpha1.Route53ZoneStatusData{HostedZoneID: "Z000001"}) + if err != nil { + t.Fatal(err) + } + unit.Status.ObservedGeneration = unit.Generation + unit.Status.Provider = &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: state}} + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{ + Provider: &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: public}}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "Zone is accepted by Route 53 policy", ObservedGeneration: unit.Spec.Zone.ObservedGeneration}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "Route 53 hosted zone is programmed", ObservedGeneration: unit.Spec.Zone.ObservedGeneration}, + }, + } + unit.Status.Conditions = []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "ZoneUnit is programmed", ObservedGeneration: unit.Generation}, + } +} + +func route53RecordSetStatusWithState(t *testing.T, recordSet *dnsv1alpha1.RecordSet, recordName string) dnsv1alpha1.ZoneUnitRecordSetStatus { + t.Helper() + raw, err := json.Marshal(route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: recordName, + RecordType: string(recordSet.Spec.Type), + }) + if err != nil { + t.Fatal(err) + } + return dnsv1alpha1.ZoneUnitRecordSetStatus{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, + Provider: &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: raw}}, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "RecordSet is accepted by Route 53 policy", ObservedGeneration: recordSet.Generation}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "Route 53 record set is programmed", ObservedGeneration: recordSet.Generation}, + }, + } +} + +func zoneUnitRecordSetStatus(t *testing.T, unit *dnsv1alpha1.ZoneUnit, namespace, name string) dnsv1alpha1.ZoneUnitRecordSetStatus { + t.Helper() + for _, status := range unit.Status.RecordSets { + if status.RecordSetNamespace == namespace && status.RecordSetName == name { + return status + } + } + t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", namespace, name, unit.Status.RecordSets) + return dnsv1alpha1.ZoneUnitRecordSetStatus{} +} + +func hasRecordSetFinalizer(recordSet *dnsv1alpha1.RecordSet, finalizer string) bool { + for _, value := range recordSet.Finalizers { + if value == finalizer { + return true + } + } + return false +} diff --git a/internal/go/providers/route53/controllers/zoneunit/status_test.go b/internal/go/providers/route53/controllers/zoneunit/status_test.go index 39808a6..c609230 100644 --- a/internal/go/providers/route53/controllers/zoneunit/status_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/status_test.go @@ -67,6 +67,7 @@ func TestZoneUnitProgrammedConditionAggregatesRoute53Targets(t *testing.T) { t.Run(tt.name, func(t *testing.T) { unit := route53ZoneUnitWithRecordSetItem("app", "apps-example-com", "app", "www-a", "www", dnsv1alpha1.RecordTypeA) unit.Generation = 7 + unit.Spec.RecordSets[0].ObservedGeneration = 1 unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{ Conditions: []metav1.Condition{ {Type: string(dnsv1alpha1.ConditionProgrammed), Status: tt.zoneStatus, Reason: tt.zoneReason, ObservedGeneration: 1}, @@ -77,6 +78,8 @@ func TestZoneUnitProgrammedConditionAggregatesRoute53Targets(t *testing.T) { { RecordSetNamespace: "app", RecordSetName: "www-a", + RecordSetUID: unit.Spec.RecordSets[0].RecordSetUID, + ObservedGeneration: unit.Spec.RecordSets[0].ObservedGeneration, Conditions: []metav1.Condition{ {Type: string(dnsv1alpha1.ConditionProgrammed), Status: tt.recordSetStatus, Reason: tt.recordSetReason, ObservedGeneration: 1}, }, @@ -90,3 +93,26 @@ func TestZoneUnitProgrammedConditionAggregatesRoute53Targets(t *testing.T) { }) } } + +func TestRoute53AggregateRequiresCurrentProgrammedObservation(t *testing.T) { + unit := route53ZoneUnitWithRecordSetItem("app", "apps-example-com", "app", "www-a", "www", dnsv1alpha1.RecordTypeA) + unit.Spec.RecordSets[0].ObservedGeneration = 2 + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{ + Conditions: []metav1.Condition{{ + Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, + Reason: "Programmed", + }}, + } + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: "app", RecordSetName: "www-a", + RecordSetUID: unit.Spec.RecordSets[0].RecordSetUID, + ObservedGeneration: 2, + Conditions: []metav1.Condition{{ + Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, + Reason: "Programmed", ObservedGeneration: 1, + }}, + }} + if status, _, _ := zoneUnitProgrammedCondition(unit); status == metav1.ConditionTrue { + t.Fatal("an Accepted refresh promoted the previous generation's Programmed observation") + } +} diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_recordset_behavior_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_recordset_behavior_test.go new file mode 100644 index 0000000..2e3a92d --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/zone_recordset_behavior_test.go @@ -0,0 +1,700 @@ +package route53 + +import ( + "context" + "fmt" + "slices" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + "github.com/aws/smithy-go" + 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 TestZoneReconcilerCreatesNullMXRecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53MXRecordSet("app", "no-mail") + recordSet.Spec.MX.Records = []dnsv1alpha1.MXRecord{{Preference: 0, Exchange: "."}} + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + if !slices.Equal(provider.upserted[0].Values, []string{"0 ."}) { + t.Fatalf("upserted values = %#v, want Null MX", provider.upserted[0].Values) + } +} +func TestZoneReconcilerCreatesCAARecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53CAARecordSet("app", "ca-policy") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeCAA { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + want := []string{`0 iodef "mailto:security@example.com"`, `0 issue "letsencrypt.org"`} + if !slices.Equal(upserted.Values, want) { + t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) + } +} +func TestZoneReconcilerMatchesCAARecordByParsedRoute53Values(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "apps.example.com.", dnsv1alpha1.RecordTypeCAA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "apps.example.com.", + Type: dnsv1alpha1.RecordTypeCAA, + TTL: &ttl, + Values: []string{`0 iodef "mailto:security@example.com"`, `0 issue "letsencrypt.org"`}, + } + recordSet := route53CAARecordSet("app", "ca-policy") + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeCAA), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} +func TestZoneReconcilerCreatesDelegatedNSRecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53NSRecordSet("app", "delegated") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "delegated.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeNS { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + want := []string{"ns-111.example-dns.net.", "ns-222.example-dns.net."} + if !slices.Equal(upserted.Values, want) { + t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) + } +} +func TestZoneReconcilerMatchesDelegatedNSRecordByParsedRoute53Values(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "delegated.apps.example.com.", dnsv1alpha1.RecordTypeNS)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "delegated.apps.example.com.", + Type: dnsv1alpha1.RecordTypeNS, + TTL: &ttl, + Values: []string{"ns-222.example-dns.net.", "ns-111.example-dns.net."}, + } + recordSet := route53NSRecordSet("app", "delegated") + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "delegated.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeNS), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} +func TestZoneReconcilerUpsertsRoute53AliasRecordSet(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53AliasARecordSet("app", "alias") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 alias change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "alias.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeA { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL != nil { + t.Fatalf("upserted ttl = %#v, want nil for alias", upserted.TTL) + } + if len(upserted.Values) != 0 { + t.Fatalf("upserted resource records = %#v, want none for alias", upserted.Values) + } + if upserted.Alias == nil { + t.Fatalf("upserted alias = nil, want AliasTarget") + } + if upserted.Alias.DNSName != "target.apps.example.com." || + upserted.Alias.HostedZoneID != "Z000001" || + upserted.Alias.EvaluateTargetHealth { + t.Fatalf("upserted alias = %#v", upserted.Alias) + } +} +func TestZoneReconcilerAdoptsMatchingRoute53AliasRecordSet(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + provider.records[recordKey("Z000001", "alias.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "alias.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + Alias: &route53v1alpha1.Route53AliasTarget{ + DNSName: "k8s-public-123456.ap-northeast-1.elb.amazonaws.com.", + HostedZoneID: "/hostedzone/Z14GRHDCWA56QT", + EvaluateTargetHealth: true, + }, + } + recordSet := route53AliasARecordSet("app", "alias") + recordSet.Spec.Adoption = runtime.RawExtension{Raw: []byte(`{"enabled":true}`)} + recordSet.Spec.Options = runtime.RawExtension{Raw: []byte(`{"alias":{"dnsName":"dualstack.k8s-public-123456.ap-northeast-1.elb.amazonaws.com.","hostedZoneID":"Z14GRHDCWA56QT","evaluateTargetHealth":true}}`)} + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name + }) + if index < 0 { + t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSet.Namespace, recordSet.Name, unit.Status.RecordSets) + } + assertCondition(t, unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") +} +func TestZoneReconcilerRejectsRecordSetWhenCNAMEZoneUnitItemOwnsSameName(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53ARecordSet("app", "www") + objects := []client.Object{ + route53Provider(), + route53ZoneClass("app", "route53-public", nil), + acceptedRoute53Identity("app", "route53-dev"), + route53ReadyZone(t, "app", "apps-example-com"), + route53ZoneUnitWithRecordSetItem("app", "apps-example-com", "other", "www-cname", "www", dnsv1alpha1.RecordTypeCNAME), + recordSet, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} +func TestZoneReconcilerMatchesAAAARecordByParsedAddress(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeAAAA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeAAAA, + TTL: &ttl, + Values: []string{"2001:0db8:0000::0010"}, + } + recordSet := route53AAAARecordSet("app", "www-v6") + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "www.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeAAAA), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} +func TestZoneReconcilerMatchesEscapedRoute53WildcardRecordName(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "\\052.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "\\052.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + TTL: &ttl, + Values: []string{"192.0.2.10"}, + } + recordSet := route53ARecordSet("app", "wildcard") + recordSet.Spec.Name = "*" + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "*.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeA), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") + if gotRecordSet.Status.Provider != nil { + t.Fatalf("status.provider = %#v, want no Route 53 RecordSet provider data", gotRecordSet.Status.Provider) + } +} +func TestZoneReconcilerRecordsRoute53RecordSetEventsOnRecordSets(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + recorder := &capturingRecorder{} + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53ARecordSet("app", "www") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider, Recorder: recorder} + + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if !recorder.has("RecordSet", "Route53RecordSetChangeSubmitted") { + t.Fatalf("Route53RecordSetChangeSubmitted was not recorded on a RecordSet: %#v", recorder.events) + } + if recorder.has("Zone", "Route53RecordSetChangeSubmitted") { + t.Fatalf("Route53RecordSetChangeSubmitted was recorded on a Zone: %#v", recorder.events) + } + + var zone dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &zone); err != nil { + t.Fatalf("Zone was not found: %v", err) + } + pending := mustZoneUnitStatusData(t, ctx, k8sClient, zone.Namespace, zone.Name).PendingRecordSetChange + if pending == nil || pending.ID == "" { + t.Fatalf("pendingRecordSetChange = %#v, want pending record set change", pending) + } + // Core projection reads this receipt to update the child claim; it does not + // clear the provider-owned receipt while Route 53 still reports the change. + provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync + + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { + t.Fatalf("second Reconcile returned error: %v", err) + } + if !recorder.has("RecordSet", "Route53RecordSetChangeInSync") { + t.Fatalf("Route53RecordSetChangeInSync was not recorded on a RecordSet: %#v", recorder.events) + } + if recorder.has("Zone", "Route53RecordSetChangeInSync") { + t.Fatalf("Route53RecordSetChangeInSync was recorded on a Zone: %#v", recorder.events) + } + statusData := mustZoneUnitRecordSetStatusState(t, ctx, k8sClient, zone.Namespace, zone.Name, recordSet.Namespace, recordSet.Name) + if statusData.HostedZoneID != "Z000001" || statusData.RecordName != "www.apps.example.com." || statusData.RecordType != string(dnsv1alpha1.RecordTypeA) { + t.Fatalf("ZoneUnit recordSet provider state = %#v, want Route 53 record identity after INSYNC", statusData) + } + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: zone.Name}, &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found after INSYNC: %v", err) + } + statusIndex := slices.IndexFunc(gotUnit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name + }) + if statusIndex < 0 { + t.Fatalf("ZoneUnit status.recordSets has no entry after INSYNC for %s/%s: %#v", recordSet.Namespace, recordSet.Name, gotUnit.Status.RecordSets) + } + assertCondition(t, gotUnit.Status.RecordSets[statusIndex].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") +} +func TestZoneReconcilerDeletesRecordSetFromSpecIdentity(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + } + ttl := int64(300) + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeA, + TTL: &ttl, + Values: []string{"192.0.2.10"}, + } + recordSet := deletingRoute53ARecordSet(t) + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "ZSTALE", + RecordName: "stale.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeA), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 deletion") + } + if len(provider.deletedRRs) != 1 { + t.Fatalf("deleted record sets = %d, want 1", len(provider.deletedRRs)) + } + deleted := provider.deletedRRs[0] + if deleted.HostedZoneID != "Z000001" || deleted.Name != "www.apps.example.com." || deleted.Type != dnsv1alpha1.RecordTypeA { + t.Fatalf("deleted identity = %#v", deleted) + } + + var zone dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &zone); err != nil { + t.Fatalf("Zone was not found: %v", err) + } + pending := mustZoneUnitStatusData(t, ctx, k8sClient, zone.Namespace, zone.Name).PendingRecordSetChange + if pending == nil || pending.ID == "" { + t.Fatalf("pendingRecordSetChange = %#v, want pending delete", pending) + } + provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync + + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { + t.Fatalf("second Reconcile returned error: %v", err) + } + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { + t.Fatalf("third Reconcile returned error: %v", err) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + statusIndex := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name + }) + if statusIndex < 0 || + !unit.Status.RecordSets[statusIndex].DeletionCompleted || + unit.Status.RecordSets[statusIndex].RecordSetUID != recordSet.UID { + t.Fatalf("ZoneUnit recordSet deletion completion = %#v, want current UID completed", unit.Status.RecordSets) + } +} +func TestZoneReconcilerIgnoresCompletedDeletionStatusForActiveZoneUnitItem(t *testing.T) { + ctx := context.Background() + recordSet := route53ARecordSet("app", "www-a") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: "app", + RecordSetName: "www-a", + RecordSetUID: recordSet.UID, + ObservedGeneration: 2, + DeletionCompleted: true, + Provider: &dnsv1alpha1.ProviderStatus{ + Data: runtime.RawExtension{Raw: []byte(`{"hostedZoneID":"Z000001","recordName":"www.apps.example.com.","recordType":"A"}`)}, + }, + Conditions: []metav1.Condition{ + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, + }, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(unit). + Build() + reconciler := &ZoneReconciler{Client: k8sClient} + + recordSets, err := reconciler.recordSetsForZone(ctx, route53ReadyZone(t, "app", "apps-example-com")) + if err != nil { + t.Fatalf("recordSetsForZone returned error: %v", err) + } + if len(recordSets) != 1 { + t.Fatalf("recordSets = %#v, want one record set", recordSets) + } + got := recordSets[0] + if got.Status.Provider != nil { + t.Fatalf("provider status = %#v, want nil", got.Status.Provider) + } + if len(got.Status.Conditions) != 0 { + t.Fatalf("conditions = %#v, want empty", got.Status.Conditions) + } + if got.Status.ObservedGeneration != recordSet.Generation { + t.Fatalf("observedGeneration = %d, want %d", got.Status.ObservedGeneration, recordSet.Generation) + } +} +func TestProviderErrorConditionSanitizesAWSAPIError(t *testing.T) { + err := &smithy.OperationError{ + ServiceID: "STS", + OperationName: "GetCallerIdentity", + Err: fmt.Errorf( + "https response error StatusCode: 400, RequestID: req-1, %w", + &smithy.GenericAPIError{ + Code: "InvalidGrantException", + Message: "SSO token could not be refreshed", + }, + ), + } + + reason, message := providerErrorCondition(err) + + if reason != "ProviderAccessDenied" { + t.Fatalf("reason = %q, want ProviderAccessDenied", reason) + } + if message != "InvalidGrantException: SSO token could not be refreshed" { + t.Fatalf("message = %q", message) + } +} +func TestNormalizeRoute53RecordName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "adds trailing dot and lowercases", + in: "WWW.Apps.Example.Com", + want: "www.apps.example.com.", + }, + { + name: "unescapes route53 wildcard octal", + in: "\\052.Platform2.Test.", + want: "*.platform2.test.", + }, + { + name: "keeps non octal escape literal", + in: "\\x52.platform2.test.", + want: "\\x52.platform2.test.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeRoute53RecordName(tt.in); got != tt.want { + t.Fatalf("normalizeRoute53RecordName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_recordset_lifecycle_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_recordset_lifecycle_test.go new file mode 100644 index 0000000..f4d56f6 --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/zone_recordset_lifecycle_test.go @@ -0,0 +1,628 @@ +package route53 + +import ( + "context" + "slices" + "strings" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + "github.com/aws/smithy-go" + corev1 "k8s.io/api/core/v1" + 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 TestZoneReconcilerCreatesARecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53ARecordSet("app", "www") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeA { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + if !slices.Equal(upserted.Values, []string{"192.0.2.10"}) { + t.Fatalf("upserted values = %#v", upserted.Values) + } + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") + if !slices.Contains(gotRecordSet.Finalizers, "dns.appthrust.io/recordset-finalizer") { + t.Fatalf("finalizers = %#v, want core RecordSet finalizer", gotRecordSet.Finalizers) + } + statusData := mustZoneUnitRecordSetStatusState(t, ctx, k8sClient, "app", "apps-example-com", "app", "www") + if statusData.HostedZoneID != "Z000001" || statusData.RecordName != "www.apps.example.com." || statusData.RecordType != string(dnsv1alpha1.RecordTypeA) { + t.Fatalf("ZoneUnit recordSet provider state = %#v, want Route 53 record identity", statusData) + } + if gotRecordSet.Status.Provider != nil && len(gotRecordSet.Status.Provider.Data.Raw) > 0 { + t.Fatalf("status.provider.data = %s, want Route 53 record identity only in state", string(gotRecordSet.Status.Provider.Data.Raw)) + } + + var gotZone dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &gotZone); err != nil { + t.Fatalf("Zone was not found: %v", err) + } + unitData := mustZoneUnitStatusData(t, ctx, k8sClient, gotZone.Namespace, gotZone.Name) + if unitData.PendingRecordSetChange == nil || unitData.PendingRecordSetChange.Operation != "UPSERT_BATCH" { + t.Fatalf("pendingRecordSetChange = %#v, want UPSERT batch", unitData.PendingRecordSetChange) + } +} +func TestZoneReconcilerUpsertsRecordSetSelectedByZoneUnitAcrossNamespaces(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53ARecordSet("record-ns", "www") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name + }) + if index < 0 { + t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSet.Namespace, recordSet.Name, unit.Status.RecordSets) + } + assertCondition(t, unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + +} +func TestZoneReconcilerAcceptsRecordSetAdoptionFromDerivedIdentity(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53ARecordSet("app", "www") + recordSet.Spec.Adoption = runtime.RawExtension{Raw: []byte(`{"enabled":true}`)} + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "www.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeA), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var gotRecordSet dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + if len(provider.deletedRRs) != 0 { + t.Fatalf("deleted record sets = %d, want 0", len(provider.deletedRRs)) + } +} +func TestZoneReconcilerClassifiesNotAllowedByZoneMessage(t *testing.T) { + ctx := context.Background() + zone := &dnsv1alpha1.Zone{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, + Spec: dnsv1alpha1.ZoneSpec{ + Provider: route53v1alpha1.ProviderRef, + AllowedRecordSets: []dnsv1alpha1.AllowedRecordSet{ + { + Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ + Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, + }, + Records: []dnsv1alpha1.AllowedRecord{ + { + Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, + Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}, + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + namespaceLabels map[string]string + recordName string + recordType dnsv1alpha1.RecordType + want string + }{ + { + name: "namespace", + namespaceLabels: nil, + recordName: "www", + recordType: dnsv1alpha1.RecordTypeA, + want: "RecordSet namespace is not allowed by the referenced Zone.", + }, + { + name: "record-name", + namespaceLabels: map[string]string{"access": "dns"}, + recordName: "api", + recordType: dnsv1alpha1.RecordTypeA, + want: "RecordSet name is not allowed by the referenced Zone.", + }, + { + name: "record-type", + namespaceLabels: map[string]string{"access": "dns"}, + recordName: "www", + recordType: dnsv1alpha1.RecordTypeAAAA, + want: "RecordSet type is not allowed for this RecordSet name by the referenced Zone.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recordSet := route53ARecordSet("record-ns", "www") + recordSet.Spec.Name = tt.recordName + recordSet.Spec.Type = tt.recordType + reconciler := &ZoneReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns", Labels: tt.namespaceLabels}}). + Build(), + } + + allowed, message := reconciler.recordSetAllowedByZoneMessage(ctx, recordSet, zone) + if allowed { + t.Fatalf("recordSetAllowedByZoneMessage returned allowed, want denied") + } + if message != tt.want { + t.Fatalf("message = %q, want %q", message, tt.want) + } + }) + } +} +func TestValidateRecordSetTXTValuesRejectsNonPrintableASCII(t *testing.T) { + tests := map[string]string{ + "newline": "line1\nline2", + "tab": "one\ttwo", + "control": "prefix\x7fsuffix", + "non-ascii": "exämple", + } + for name, value := range tests { + t.Run(name, func(t *testing.T) { + err := validateRecordSetTXTValues([]string{value}) + if err == nil || !strings.Contains(err.Error(), "printable ASCII") { + t.Fatalf("validateRecordSetTXTValues error = %v, want printable ASCII failure", err) + } + }) + } +} +func TestZoneReconcilerCreatesAAAARecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53AAAARecordSet("app", "www-v6") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeAAAA { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + if !slices.Equal(upserted.Values, []string{"2001:db8::10"}) { + t.Fatalf("upserted values = %#v", upserted.Values) + } +} +func TestZoneReconcilerCreatesTXTRecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53TXTRecordSet("app", "acme-challenge") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "_acme-challenge.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeTXT { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + want := []string{`"challenge-token"`, `"v=spf1 include:_spf.example.net ~all"`} + if !slices.Equal(upserted.Values, want) { + t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) + } +} +func TestZoneReconcilerDoesNotPropagateInvalidBatchErrorToUnrelatedRecordSet(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + provider.changeRecordSetsErr = func(_ string, changes []RecordSetChange) error { + if len(changes) > 1 || changes[0].RecordSet.Name == "alias.apps.example.com." { + return &smithy.GenericAPIError{ + Code: "InvalidChangeBatch", + Message: "Tried to create an alias that targets https://not-a-dns-name.example.com.", + } + } + return nil + } + alias := route53AliasARecordSet("app", "bad-alias") + alias.Spec.Name = "alias" + txt := route53TXTRecordSet("app", "acme-challenge") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ + route53ZoneUnitRecordSetSpec(alias), + route53ZoneUnitRecordSetSpec(txt), + } + objects := []client.Object{ + route53Provider(), + route53ZoneClass("app", "route53-public", nil), + acceptedRoute53Identity("app", "route53-dev"), + route53ReadyZone(t, "app", "apps-example-com"), + unit, + alias, + txt, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for the retried Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + if provider.upserted[0].Type != dnsv1alpha1.RecordTypeTXT { + t.Fatalf("upserted record set = %#v, want TXT only", provider.upserted[0]) + } + + var gotUnit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &gotUnit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + aliasIndex := slices.IndexFunc(gotUnit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == "app" && status.RecordSetName == "bad-alias" + }) + if aliasIndex < 0 { + t.Fatalf("ZoneUnit status has no bad-alias entry: %#v", gotUnit.Status.RecordSets) + } + aliasProgrammed := assertCondition(t, gotUnit.Status.RecordSets[aliasIndex].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderInvalidRequest") + if !strings.Contains(aliasProgrammed.Message, "https://not-a-dns-name") { + t.Fatalf("alias Programmed message = %q, want provider detail", aliasProgrammed.Message) + } + txtIndex := slices.IndexFunc(gotUnit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == "app" && status.RecordSetName == "acme-challenge" + }) + if txtIndex < 0 { + t.Fatalf("ZoneUnit status has no acme-challenge entry: %#v", gotUnit.Status.RecordSets) + } + txtProgrammed := assertCondition(t, gotUnit.Status.RecordSets[txtIndex].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") + if strings.Contains(txtProgrammed.Message, "https://not-a-dns-name") { + t.Fatalf("TXT Programmed message leaked alias error: %q", txtProgrammed.Message) + } +} +func TestZoneReconcilerCreatesCNAMERecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53CNAMERecordSet("app", "www-cname") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeCNAME { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + if !slices.Equal(upserted.Values, []string{"target.example.net."}) { + t.Fatalf("upserted values = %#v", upserted.Values) + } +} +func TestZoneReconcilerMatchesTXTRecordByParsedRoute53Chunks(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "_acme-challenge.apps.example.com.", dnsv1alpha1.RecordTypeTXT)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "_acme-challenge.apps.example.com.", + Type: dnsv1alpha1.RecordTypeTXT, + TTL: &ttl, + Values: []string{`"v=spf1 " "include:_spf.example.net ~all"`, `"challenge-token"`}, + } + recordSet := route53TXTRecordSet("app", "acme-challenge") + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "_acme-challenge.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeTXT), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} +func TestZoneReconcilerMatchesCNAMERecordByCanonicalRoute53Name(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeCNAME)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "www.apps.example.com.", + Type: dnsv1alpha1.RecordTypeCNAME, + TTL: &ttl, + Values: []string{"target.example.net."}, + } + recordSet := route53CNAMERecordSet("app", "www-cname") + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "www.apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeCNAME), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} +func TestZoneReconcilerCreatesMXRecordFromZoneReconcile(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + recordSet := route53MXRecordSet("app", "mail") + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter == 0 { + t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "Z000001" || upserted.Name != "apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeMX { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 { + t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) + } + want := []string{"10 mail1.example.net.", "20 mail2.example.net."} + if !slices.Equal(upserted.Values, want) { + t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) + } +} +func TestZoneReconcilerMatchesMXRecordByParsedRoute53Values(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + provider := newFakeProvider() + provider.zones["Z000001"] = HostedZone{ + ID: "Z000001", + Name: "apps.example.com", + CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + ttl := int64(300) + provider.records[recordKey("Z000001", "apps.example.com.", dnsv1alpha1.RecordTypeMX)] = RecordSetResource{ + HostedZoneID: "Z000001", + Name: "apps.example.com.", + Type: dnsv1alpha1.RecordTypeMX, + TTL: &ttl, + Values: []string{"20 mail2.example.net.", "10 mail1.example.net."}, + } + recordSet := route53MXRecordSet("app", "mail") + setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ + HostedZoneID: "Z000001", + RecordName: "apps.example.com.", + RecordType: string(dnsv1alpha1.RecordTypeMX), + }) + objects := route53RecordSetObjects(t, recordSet) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + + result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.Requeue { + t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) + } + if len(provider.upserted) != 0 { + t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + } +} diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_status.go b/internal/go/providers/route53/controllers/zoneunit/zone_status.go index a91911e..3f21286 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_status.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_status.go @@ -223,12 +223,18 @@ func zoneUnitProgrammedCondition(unit *dnsv1alpha1.ZoneUnit) (metav1.ConditionSt func zoneUnitRecordSetProgrammedCondition(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitRecordSetSpec) *metav1.Condition { index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == item.RecordSetNamespace && status.RecordSetName == item.RecordSetName + return zoneUnitRecordSetStatusMatchesItem(status, item) }) if index < 0 { return nil } - return meta.FindStatusCondition(unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionProgrammed)) + status := unit.Status.RecordSets[index] + condition := meta.FindStatusCondition(status.Conditions, string(dnsv1alpha1.ConditionProgrammed)) + if status.ObservedGeneration != item.ObservedGeneration || condition == nil || + condition.ObservedGeneration != item.ObservedGeneration { + return nil + } + return condition } func (r *ZoneReconciler) patchRoute53ZoneStatus(ctx context.Context, zone *dnsv1alpha1.Zone, mutate func(*route53v1alpha1.Route53ZoneStatusData)) error { diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_test.go index 73cd5c8..2410a35 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_test.go @@ -3,9 +3,7 @@ package route53 import ( "context" "encoding/json" - "fmt" "slices" - "strings" "testing" "time" @@ -13,11 +11,9 @@ import ( zoneclass "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneclass" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" - "github.com/aws/smithy-go" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -51,21 +47,14 @@ func TestZoneReconcilerCreatesHostedZone(t *testing.T) { route53Provider(), zoneClass, acceptedRoute53Identity("tenant-a-platform", "route53-dev"), - &dnsv1alpha1.Zone{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "tenant-a-app", - Name: "apps-example-com", - UID: types.UID("11111111-2222-3333-4444-555555555555"), - }, - Spec: dnsv1alpha1.ZoneSpec{ - DomainName: "apps.example.com", - Provider: route53v1alpha1.ProviderRef, - ZoneClassRef: dnsv1alpha1.ZoneClassReference{ - Namespace: ptr("tenant-a-platform"), - Name: "route53-public", - }, + &dnsv1alpha1.Zone{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a-app", Name: "apps-example-com", UID: types.UID("11111111-2222-3333-4444-555555555555"), Generation: 1}, Spec: dnsv1alpha1.ZoneSpec{ + DomainName: "apps.example.com", + Provider: route53v1alpha1.ProviderRef, + ZoneClassRef: dnsv1alpha1.ZoneClassReference{ + Namespace: ptr("tenant-a-platform"), + Name: "route53-public", }, - }, + }}, route53ZoneUnit("tenant-a-app", "apps-example-com", "tenant-a-platform", "route53-public"), } k8sClient := fake.NewClientBuilder(). @@ -867,2042 +856,3 @@ func TestZoneReconcilerDoesNotDeleteHostedZoneWithoutDeletionTarget(t *testing.T t.Fatalf("ZoneUnit finalizers = %#v, want %q removed without deletion target", unit.Finalizers, ZoneFinalizer) } } - -func TestZoneReconcilerCreatesARecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53ARecordSet("app", "www") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeA { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - if !slices.Equal(upserted.Values, []string{"192.0.2.10"}) { - t.Fatalf("upserted values = %#v", upserted.Values) - } - - var gotRecordSet dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") - assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") - if !slices.Contains(gotRecordSet.Finalizers, "dns.appthrust.io/recordset-finalizer") { - t.Fatalf("finalizers = %#v, want core RecordSet finalizer", gotRecordSet.Finalizers) - } - statusData := mustZoneUnitRecordSetStatusState(t, ctx, k8sClient, "app", "apps-example-com", "app", "www") - if statusData.HostedZoneID != "Z000001" || statusData.RecordName != "www.apps.example.com." || statusData.RecordType != string(dnsv1alpha1.RecordTypeA) { - t.Fatalf("ZoneUnit recordSet provider state = %#v, want Route 53 record identity", statusData) - } - if gotRecordSet.Status.Provider != nil && len(gotRecordSet.Status.Provider.Data.Raw) > 0 { - t.Fatalf("status.provider.data = %s, want Route 53 record identity only in state", string(gotRecordSet.Status.Provider.Data.Raw)) - } - - var gotZone dnsv1alpha1.Zone - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &gotZone); err != nil { - t.Fatalf("Zone was not found: %v", err) - } - unitData := mustZoneUnitStatusData(t, ctx, k8sClient, gotZone.Namespace, gotZone.Name) - if unitData.PendingRecordSetChange == nil || unitData.PendingRecordSetChange.Operation != "UPSERT_BATCH" { - t.Fatalf("pendingRecordSetChange = %#v, want UPSERT batch", unitData.PendingRecordSetChange) - } -} - -func TestZoneReconcilerUpsertsRecordSetSelectedByZoneUnitAcrossNamespaces(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53ARecordSet("record-ns", "www") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) - if index < 0 { - t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSet.Namespace, recordSet.Name, unit.Status.RecordSets) - } - assertCondition(t, unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") - -} - -func TestZoneReconcilerAcceptsRecordSetAdoptionFromDerivedIdentity(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53ARecordSet("app", "www") - recordSet.Spec.Adoption = runtime.RawExtension{Raw: []byte(`{"enabled":true}`)} - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "www.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeA), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotRecordSet dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") - if len(provider.deletedRRs) != 0 { - t.Fatalf("deleted record sets = %d, want 0", len(provider.deletedRRs)) - } -} - -func TestZoneReconcilerClassifiesNotAllowedByZoneMessage(t *testing.T) { - ctx := context.Background() - zone := &dnsv1alpha1.Zone{ - ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "apps-example-com"}, - Spec: dnsv1alpha1.ZoneSpec{ - Provider: route53v1alpha1.ProviderRef, - AllowedRecordSets: []dnsv1alpha1.AllowedRecordSet{ - { - Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{ - Selector: metav1.LabelSelector{MatchLabels: map[string]string{"access": "dns"}}, - }, - Records: []dnsv1alpha1.AllowedRecord{ - { - Name: dnsv1alpha1.RecordNamePolicy{Pattern: "www"}, - Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}, - }, - }, - }, - }, - }, - } - - tests := []struct { - name string - namespaceLabels map[string]string - recordName string - recordType dnsv1alpha1.RecordType - want string - }{ - { - name: "namespace", - namespaceLabels: nil, - recordName: "www", - recordType: dnsv1alpha1.RecordTypeA, - want: "RecordSet namespace is not allowed by the referenced Zone.", - }, - { - name: "record-name", - namespaceLabels: map[string]string{"access": "dns"}, - recordName: "api", - recordType: dnsv1alpha1.RecordTypeA, - want: "RecordSet name is not allowed by the referenced Zone.", - }, - { - name: "record-type", - namespaceLabels: map[string]string{"access": "dns"}, - recordName: "www", - recordType: dnsv1alpha1.RecordTypeAAAA, - want: "RecordSet type is not allowed for this RecordSet name by the referenced Zone.", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recordSet := route53ARecordSet("record-ns", "www") - recordSet.Spec.Name = tt.recordName - recordSet.Spec.Type = tt.recordType - reconciler := &ZoneReconciler{ - Client: fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "record-ns", Labels: tt.namespaceLabels}}). - Build(), - } - - allowed, message := reconciler.recordSetAllowedByZoneMessage(ctx, recordSet, zone) - if allowed { - t.Fatalf("recordSetAllowedByZoneMessage returned allowed, want denied") - } - if message != tt.want { - t.Fatalf("message = %q, want %q", message, tt.want) - } - }) - } -} - -func TestValidateRecordSetTXTValuesRejectsNonPrintableASCII(t *testing.T) { - tests := map[string]string{ - "newline": "line1\nline2", - "tab": "one\ttwo", - "control": "prefix\x7fsuffix", - "non-ascii": "exämple", - } - for name, value := range tests { - t.Run(name, func(t *testing.T) { - err := validateRecordSetTXTValues([]string{value}) - if err == nil || !strings.Contains(err.Error(), "printable ASCII") { - t.Fatalf("validateRecordSetTXTValues error = %v, want printable ASCII failure", err) - } - }) - } -} - -func TestZoneReconcilerCreatesAAAARecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53AAAARecordSet("app", "www-v6") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeAAAA { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - if !slices.Equal(upserted.Values, []string{"2001:db8::10"}) { - t.Fatalf("upserted values = %#v", upserted.Values) - } -} - -func TestZoneReconcilerCreatesTXTRecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53TXTRecordSet("app", "acme-challenge") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "_acme-challenge.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeTXT { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - want := []string{`"challenge-token"`, `"v=spf1 include:_spf.example.net ~all"`} - if !slices.Equal(upserted.Values, want) { - t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) - } -} - -func TestZoneReconcilerDoesNotPropagateInvalidBatchErrorToUnrelatedRecordSet(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - provider.changeRecordSetsErr = func(_ string, changes []RecordSetChange) error { - if len(changes) > 1 || changes[0].RecordSet.Name == "alias.apps.example.com." { - return &smithy.GenericAPIError{ - Code: "InvalidChangeBatch", - Message: "Tried to create an alias that targets https://not-a-dns-name.example.com.", - } - } - return nil - } - alias := route53AliasARecordSet("app", "bad-alias") - alias.Spec.Name = "alias" - txt := route53TXTRecordSet("app", "acme-challenge") - unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{ - route53ZoneUnitRecordSetSpec(alias), - route53ZoneUnitRecordSetSpec(txt), - } - objects := []client.Object{ - route53Provider(), - route53ZoneClass("app", "route53-public", nil), - acceptedRoute53Identity("app", "route53-dev"), - route53ReadyZone(t, "app", "apps-example-com"), - unit, - alias, - txt, - } - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for the retried Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - if provider.upserted[0].Type != dnsv1alpha1.RecordTypeTXT { - t.Fatalf("upserted record set = %#v, want TXT only", provider.upserted[0]) - } - - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - aliasIndex := slices.IndexFunc(gotUnit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == "app" && status.RecordSetName == "bad-alias" - }) - if aliasIndex < 0 { - t.Fatalf("ZoneUnit status has no bad-alias entry: %#v", gotUnit.Status.RecordSets) - } - aliasProgrammed := assertCondition(t, gotUnit.Status.RecordSets[aliasIndex].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderInvalidRequest") - if !strings.Contains(aliasProgrammed.Message, "https://not-a-dns-name") { - t.Fatalf("alias Programmed message = %q, want provider detail", aliasProgrammed.Message) - } - txtIndex := slices.IndexFunc(gotUnit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == "app" && status.RecordSetName == "acme-challenge" - }) - if txtIndex < 0 { - t.Fatalf("ZoneUnit status has no acme-challenge entry: %#v", gotUnit.Status.RecordSets) - } - txtProgrammed := assertCondition(t, gotUnit.Status.RecordSets[txtIndex].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ProviderChangePending") - if strings.Contains(txtProgrammed.Message, "https://not-a-dns-name") { - t.Fatalf("TXT Programmed message leaked alias error: %q", txtProgrammed.Message) - } -} - -func TestZoneReconcilerCreatesCNAMERecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53CNAMERecordSet("app", "www-cname") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeCNAME { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - if !slices.Equal(upserted.Values, []string{"target.example.net."}) { - t.Fatalf("upserted values = %#v", upserted.Values) - } -} - -func TestZoneReconcilerMatchesTXTRecordByParsedRoute53Chunks(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "_acme-challenge.apps.example.com.", dnsv1alpha1.RecordTypeTXT)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "_acme-challenge.apps.example.com.", - Type: dnsv1alpha1.RecordTypeTXT, - TTL: &ttl, - Values: []string{`"v=spf1 " "include:_spf.example.net ~all"`, `"challenge-token"`}, - } - recordSet := route53TXTRecordSet("app", "acme-challenge") - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "_acme-challenge.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeTXT), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerMatchesCNAMERecordByCanonicalRoute53Name(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeCNAME)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "www.apps.example.com.", - Type: dnsv1alpha1.RecordTypeCNAME, - TTL: &ttl, - Values: []string{"target.example.net."}, - } - recordSet := route53CNAMERecordSet("app", "www-cname") - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "www.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeCNAME), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerCreatesMXRecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53MXRecordSet("app", "mail") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeMX { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - want := []string{"10 mail1.example.net.", "20 mail2.example.net."} - if !slices.Equal(upserted.Values, want) { - t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) - } -} - -func TestZoneReconcilerMatchesMXRecordByParsedRoute53Values(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "apps.example.com.", dnsv1alpha1.RecordTypeMX)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "apps.example.com.", - Type: dnsv1alpha1.RecordTypeMX, - TTL: &ttl, - Values: []string{"20 mail2.example.net.", "10 mail1.example.net."}, - } - recordSet := route53MXRecordSet("app", "mail") - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeMX), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerCreatesNullMXRecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53MXRecordSet("app", "no-mail") - recordSet.Spec.MX.Records = []dnsv1alpha1.MXRecord{{Preference: 0, Exchange: "."}} - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - if !slices.Equal(provider.upserted[0].Values, []string{"0 ."}) { - t.Fatalf("upserted values = %#v, want Null MX", provider.upserted[0].Values) - } -} - -func TestZoneReconcilerCreatesCAARecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53CAARecordSet("app", "ca-policy") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeCAA { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - want := []string{`0 iodef "mailto:security@example.com"`, `0 issue "letsencrypt.org"`} - if !slices.Equal(upserted.Values, want) { - t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) - } -} - -func TestZoneReconcilerMatchesCAARecordByParsedRoute53Values(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "apps.example.com.", dnsv1alpha1.RecordTypeCAA)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "apps.example.com.", - Type: dnsv1alpha1.RecordTypeCAA, - TTL: &ttl, - Values: []string{`0 iodef "mailto:security@example.com"`, `0 issue "letsencrypt.org"`}, - } - recordSet := route53CAARecordSet("app", "ca-policy") - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeCAA), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerCreatesDelegatedNSRecordFromZoneReconcile(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53NSRecordSet("app", "delegated") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 record change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "delegated.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeNS { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL == nil || *upserted.TTL != 300 { - t.Fatalf("upserted ttl = %#v, want 300", upserted.TTL) - } - want := []string{"ns-111.example-dns.net.", "ns-222.example-dns.net."} - if !slices.Equal(upserted.Values, want) { - t.Fatalf("upserted values = %#v, want %#v", upserted.Values, want) - } -} - -func TestZoneReconcilerMatchesDelegatedNSRecordByParsedRoute53Values(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "delegated.apps.example.com.", dnsv1alpha1.RecordTypeNS)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "delegated.apps.example.com.", - Type: dnsv1alpha1.RecordTypeNS, - TTL: &ttl, - Values: []string{"ns-222.example-dns.net.", "ns-111.example-dns.net."}, - } - recordSet := route53NSRecordSet("app", "delegated") - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "delegated.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeNS), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerUpsertsRoute53AliasRecordSet(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53AliasARecordSet("app", "alias") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 alias change") - } - if len(provider.upserted) != 1 { - t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) - } - upserted := provider.upserted[0] - if upserted.HostedZoneID != "Z000001" || upserted.Name != "alias.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeA { - t.Fatalf("upserted identity = %#v", upserted) - } - if upserted.TTL != nil { - t.Fatalf("upserted ttl = %#v, want nil for alias", upserted.TTL) - } - if len(upserted.Values) != 0 { - t.Fatalf("upserted resource records = %#v, want none for alias", upserted.Values) - } - if upserted.Alias == nil { - t.Fatalf("upserted alias = nil, want AliasTarget") - } - if upserted.Alias.DNSName != "target.apps.example.com." || - upserted.Alias.HostedZoneID != "Z000001" || - upserted.Alias.EvaluateTargetHealth { - t.Fatalf("upserted alias = %#v", upserted.Alias) - } -} - -func TestZoneReconcilerAdoptsMatchingRoute53AliasRecordSet(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - provider.records[recordKey("Z000001", "alias.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "alias.apps.example.com.", - Type: dnsv1alpha1.RecordTypeA, - Alias: &route53v1alpha1.Route53AliasTarget{ - DNSName: "k8s-public-123456.ap-northeast-1.elb.amazonaws.com.", - HostedZoneID: "/hostedzone/Z14GRHDCWA56QT", - EvaluateTargetHealth: true, - }, - } - recordSet := route53AliasARecordSet("app", "alias") - recordSet.Spec.Adoption = runtime.RawExtension{Raw: []byte(`{"enabled":true}`)} - recordSet.Spec.Options = runtime.RawExtension{Raw: []byte(`{"alias":{"dnsName":"dualstack.k8s-public-123456.ap-northeast-1.elb.amazonaws.com.","hostedZoneID":"Z14GRHDCWA56QT","evaluateTargetHealth":true}}`)} - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) - if index < 0 { - t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSet.Namespace, recordSet.Name, unit.Status.RecordSets) - } - assertCondition(t, unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") -} - -func TestZoneReconcilerRejectsRecordSetWhenCNAMEZoneUnitItemOwnsSameName(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53ARecordSet("app", "www") - objects := []client.Object{ - route53Provider(), - route53ZoneClass("app", "route53-public", nil), - acceptedRoute53Identity("app", "route53-dev"), - route53ReadyZone(t, "app", "apps-example-com"), - route53ZoneUnitWithRecordSetItem("app", "apps-example-com", "other", "www-cname", "www", dnsv1alpha1.RecordTypeCNAME), - recordSet, - } - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - - var gotRecordSet dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "RecordSetConflict") - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerMatchesAAAARecordByParsedAddress(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeAAAA)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "www.apps.example.com.", - Type: dnsv1alpha1.RecordTypeAAAA, - TTL: &ttl, - Values: []string{"2001:0db8:0000::0010"}, - } - recordSet := route53AAAARecordSet("app", "www-v6") - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "www.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeAAAA), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } -} - -func TestZoneReconcilerMatchesEscapedRoute53WildcardRecordName(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - ttl := int64(300) - provider.records[recordKey("Z000001", "\\052.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "\\052.apps.example.com.", - Type: dnsv1alpha1.RecordTypeA, - TTL: &ttl, - Values: []string{"192.0.2.10"}, - } - recordSet := route53ARecordSet("app", "wildcard") - recordSet.Spec.Name = "*" - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "Z000001", - RecordName: "*.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeA), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.Requeue { - t.Fatalf("Reconcile result = %#v, want no immediate requeue", result) - } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) - } - - var gotRecordSet dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - assertCondition(t, gotRecordSet.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") - if gotRecordSet.Status.Provider != nil { - t.Fatalf("status.provider = %#v, want no Route 53 RecordSet provider data", gotRecordSet.Status.Provider) - } -} - -func TestZoneReconcilerRecordsRoute53RecordSetEventsOnRecordSets(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - recorder := &capturingRecorder{} - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - recordSet := route53ARecordSet("app", "www") - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider, Recorder: recorder} - - if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if !recorder.has("RecordSet", "Route53RecordSetChangeSubmitted") { - t.Fatalf("Route53RecordSetChangeSubmitted was not recorded on a RecordSet: %#v", recorder.events) - } - if recorder.has("Zone", "Route53RecordSetChangeSubmitted") { - t.Fatalf("Route53RecordSetChangeSubmitted was recorded on a Zone: %#v", recorder.events) - } - - var zone dnsv1alpha1.Zone - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &zone); err != nil { - t.Fatalf("Zone was not found: %v", err) - } - pending := mustZoneUnitStatusData(t, ctx, k8sClient, zone.Namespace, zone.Name).PendingRecordSetChange - if pending == nil || pending.ID == "" { - t.Fatalf("pendingRecordSetChange = %#v, want pending record set change", pending) - } - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: zone.Name}, &unit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - statusIndex := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) - if statusIndex < 0 { - t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSet.Namespace, recordSet.Name, unit.Status.RecordSets) - } - unit.Status.RecordSets[statusIndex].Provider = nil - if err := k8sClient.Status().Update(ctx, &unit); err != nil { - t.Fatalf("clear RecordSet provider status fixture: %v", err) - } - provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync - - if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { - t.Fatalf("second Reconcile returned error: %v", err) - } - if !recorder.has("RecordSet", "Route53RecordSetChangeInSync") { - t.Fatalf("Route53RecordSetChangeInSync was not recorded on a RecordSet: %#v", recorder.events) - } - if recorder.has("Zone", "Route53RecordSetChangeInSync") { - t.Fatalf("Route53RecordSetChangeInSync was recorded on a Zone: %#v", recorder.events) - } - statusData := mustZoneUnitRecordSetStatusState(t, ctx, k8sClient, zone.Namespace, zone.Name, recordSet.Namespace, recordSet.Name) - if statusData.HostedZoneID != "Z000001" || statusData.RecordName != "www.apps.example.com." || statusData.RecordType != string(dnsv1alpha1.RecordTypeA) { - t.Fatalf("ZoneUnit recordSet provider state = %#v, want Route 53 record identity after INSYNC", statusData) - } - var gotUnit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: zone.Namespace, Name: zone.Name}, &gotUnit); err != nil { - t.Fatalf("ZoneUnit was not found after INSYNC: %v", err) - } - statusIndex = slices.IndexFunc(gotUnit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) - if statusIndex < 0 { - t.Fatalf("ZoneUnit status.recordSets has no entry after INSYNC for %s/%s: %#v", recordSet.Namespace, recordSet.Name, gotUnit.Status.RecordSets) - } - assertCondition(t, gotUnit.Status.RecordSets[statusIndex].Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") -} - -func TestZoneReconcilerDeletesRecordSetFromSpecIdentity(t *testing.T) { - ctx := context.Background() - scheme := testScheme(t) - provider := newFakeProvider() - provider.zones["Z000001"] = HostedZone{ - ID: "Z000001", - Name: "apps.example.com", - CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", - } - ttl := int64(300) - provider.records[recordKey("Z000001", "www.apps.example.com.", dnsv1alpha1.RecordTypeA)] = RecordSetResource{ - HostedZoneID: "Z000001", - Name: "www.apps.example.com.", - Type: dnsv1alpha1.RecordTypeA, - TTL: &ttl, - Values: []string{"192.0.2.10"}, - } - recordSet := deletingRoute53ARecordSet(t) - setRecordSetStatusData(t, recordSet, route53v1alpha1.Route53RecordSetStatusData{ - HostedZoneID: "ZSTALE", - RecordName: "stale.apps.example.com.", - RecordType: string(dnsv1alpha1.RecordTypeA), - }) - objects := route53RecordSetObjects(t, recordSet) - k8sClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(objects...). - WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). - Build() - reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} - - result, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}) - if err != nil { - t.Fatalf("Reconcile returned error: %v", err) - } - if result.RequeueAfter == 0 { - t.Fatalf("Reconcile did not request a requeue for a pending Route 53 deletion") - } - if len(provider.deletedRRs) != 1 { - t.Fatalf("deleted record sets = %d, want 1", len(provider.deletedRRs)) - } - deleted := provider.deletedRRs[0] - if deleted.HostedZoneID != "Z000001" || deleted.Name != "www.apps.example.com." || deleted.Type != dnsv1alpha1.RecordTypeA { - t.Fatalf("deleted identity = %#v", deleted) - } - - var zone dnsv1alpha1.Zone - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &zone); err != nil { - t.Fatalf("Zone was not found: %v", err) - } - pending := mustZoneUnitStatusData(t, ctx, k8sClient, zone.Namespace, zone.Name).PendingRecordSetChange - if pending == nil || pending.ID == "" { - t.Fatalf("pendingRecordSetChange = %#v, want pending delete", pending) - } - provider.changes[pending.ID].Status = route53v1alpha1.Route53ChangeStatusInSync - - if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { - t.Fatalf("second Reconcile returned error: %v", err) - } - if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "app", Name: "apps-example-com"}}); err != nil { - t.Fatalf("third Reconcile returned error: %v", err) - } - - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "app", Name: "apps-example-com"}, &unit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - statusIndex := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSet.Namespace && status.RecordSetName == recordSet.Name - }) - if statusIndex < 0 || !unit.Status.RecordSets[statusIndex].DeletionCompleted { - t.Fatalf("ZoneUnit recordSet deletionCompleted = %#v, want true", unit.Status.RecordSets) - } -} - -func TestZoneReconcilerIgnoresCompletedDeletionStatusForActiveZoneUnitItem(t *testing.T) { - ctx := context.Background() - recordSet := route53ARecordSet("app", "www-a") - unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} - unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: "app", - RecordSetName: "www-a", - ObservedGeneration: 2, - DeletionCompleted: true, - Provider: &dnsv1alpha1.ProviderStatus{ - Data: runtime.RawExtension{Raw: []byte(`{"hostedZoneID":"Z000001","recordName":"www.apps.example.com.","recordType":"A"}`)}, - }, - Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted"}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed"}, - }, - }, - } - k8sClient := fake.NewClientBuilder(). - WithScheme(testScheme(t)). - WithObjects(unit). - Build() - reconciler := &ZoneReconciler{Client: k8sClient} - - recordSets, err := reconciler.recordSetsForZone(ctx, route53ReadyZone(t, "app", "apps-example-com")) - if err != nil { - t.Fatalf("recordSetsForZone returned error: %v", err) - } - if len(recordSets) != 1 { - t.Fatalf("recordSets = %#v, want one record set", recordSets) - } - got := recordSets[0] - if got.Status.Provider != nil { - t.Fatalf("provider status = %#v, want nil", got.Status.Provider) - } - if len(got.Status.Conditions) != 0 { - t.Fatalf("conditions = %#v, want empty", got.Status.Conditions) - } - if got.Status.ObservedGeneration != recordSet.Generation { - t.Fatalf("observedGeneration = %d, want %d", got.Status.ObservedGeneration, recordSet.Generation) - } -} - -func TestProviderErrorConditionSanitizesAWSAPIError(t *testing.T) { - err := &smithy.OperationError{ - ServiceID: "STS", - OperationName: "GetCallerIdentity", - Err: fmt.Errorf( - "https response error StatusCode: 400, RequestID: req-1, %w", - &smithy.GenericAPIError{ - Code: "InvalidGrantException", - Message: "SSO token could not be refreshed", - }, - ), - } - - reason, message := providerErrorCondition(err) - - if reason != "ProviderAccessDenied" { - t.Fatalf("reason = %q, want ProviderAccessDenied", reason) - } - if message != "InvalidGrantException: SSO token could not be refreshed" { - t.Fatalf("message = %q", message) - } -} - -func TestNormalizeRoute53RecordName(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - { - name: "adds trailing dot and lowercases", - in: "WWW.Apps.Example.Com", - want: "www.apps.example.com.", - }, - { - name: "unescapes route53 wildcard octal", - in: "\\052.Platform2.Test.", - want: "*.platform2.test.", - }, - { - name: "keeps non octal escape literal", - in: "\\x52.platform2.test.", - want: "\\x52.platform2.test.", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := normalizeRoute53RecordName(tt.in); got != tt.want { - t.Fatalf("normalizeRoute53RecordName(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} - -func testScheme(t *testing.T) *runtime.Scheme { - t.Helper() - scheme := runtime.NewScheme() - if err := corev1.AddToScheme(scheme); err != nil { - t.Fatalf("corev1 AddToScheme: %v", err) - } - if err := dnsv1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("dns AddToScheme: %v", err) - } - if err := route53v1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("route53 AddToScheme: %v", err) - } - return scheme -} - -func sameNamespaceClassObjects(namespace string, tags map[string]string) []client.Object { - return []client.Object{ - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, - route53Provider(), - route53ZoneClass(namespace, "route53-public", tags), - acceptedRoute53Identity(namespace, "route53-dev"), - route53ZoneUnit(namespace, "apps-example-com", namespace, "route53-public"), - &dnsv1alpha1.Zone{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: "apps-example-com", - UID: types.UID("11111111-2222-3333-4444-555555555555"), - }, - Spec: dnsv1alpha1.ZoneSpec{ - DomainName: "apps.example.com", - ZoneClassRef: dnsv1alpha1.ZoneClassReference{Name: "route53-public"}, - Provider: route53v1alpha1.ProviderRef, - }, - }, - } -} - -func route53ZoneClass(namespace, name string, tags map[string]string) *dnsv1alpha1.ZoneClass { - parameters := route53v1alpha1.Route53ZoneClassParameters{ - Tags: tags, - } - raw, err := json.Marshal(parameters) - if err != nil { - panic(err) - } - return &dnsv1alpha1.ZoneClass{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.ZoneClassSpec{ - Provider: route53v1alpha1.ProviderRef, - ControllerName: DefaultControllerName, - IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, - Parameters: runtime.RawExtension{Raw: raw}, - AllowedZones: dnsv1alpha1.ZoneClassAllowedZones{ - Namespaces: dnsv1alpha1.NamespacePolicy{From: dnsv1alpha1.NamespacesFromSame}, - }, - }, - Status: dnsv1alpha1.ZoneClassStatus{ - Conditions: []metav1.Condition{ - { - Type: string(dnsv1alpha1.ConditionAccepted), - Status: metav1.ConditionTrue, - Reason: "Accepted", - ObservedGeneration: 1, - }, - }, - }, - } -} - -func route53Provider() *dnsv1alpha1.Provider { - return &dnsv1alpha1.Provider{ - ObjectMeta: metav1.ObjectMeta{Name: route53v1alpha1.ProviderName}, - Spec: dnsv1alpha1.ProviderSpec{ - Display: dnsv1alpha1.ProviderDisplay{Name: "Amazon Route 53"}, - Versions: []dnsv1alpha1.ProviderVersion{ - { - Name: route53v1alpha1.ProviderVersion, - Served: true, - Storage: true, - Identity: &dnsv1alpha1.ProviderIdentity{ - Resource: dnsv1alpha1.ProviderIdentityResource{ - Group: "route53.dns.appthrust.io", - Kind: "Route53Identity", - Scope: "Namespaced", - }, - }, - RecordSet: dnsv1alpha1.ProviderRecordSet{ - SupportedTypes: []dnsv1alpha1.RecordType{ - dnsv1alpha1.RecordTypeA, - dnsv1alpha1.RecordTypeAAAA, - dnsv1alpha1.RecordTypeTXT, - dnsv1alpha1.RecordTypeCNAME, - dnsv1alpha1.RecordTypeMX, - dnsv1alpha1.RecordTypeCAA, - dnsv1alpha1.RecordTypeNS, - }, - }, - }, - }, - }, - } -} - -func route53RecordSetObjects(t *testing.T, recordSet *dnsv1alpha1.RecordSet) []client.Object { - t.Helper() - zone := route53ReadyZone(t, "app", "apps-example-com") - unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") - unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} - if recordSet.Status.Provider != nil || len(recordSet.Status.Conditions) > 0 { - unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ - { - RecordSetNamespace: recordSet.Namespace, - RecordSetName: recordSet.Name, - ObservedGeneration: recordSet.Status.ObservedGeneration, - Provider: recordSet.Status.Provider, - Conditions: slices.Clone(recordSet.Status.Conditions), - }, - } - } - return []client.Object{ - route53Provider(), - route53ZoneClass("app", "route53-public", nil), - acceptedRoute53Identity("app", "route53-dev"), - zone, - unit, - recordSet, - } -} - -func route53ZoneUnit(zoneNamespace, zoneName, zoneClassNamespace, zoneClassName string) *dnsv1alpha1.ZoneUnit { - return &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: zoneNamespace, - Name: zoneName, - UID: types.UID("11111111-2222-3333-4444-555555555555"), - }, - Spec: dnsv1alpha1.ZoneUnitSpec{ - Provider: dnsv1alpha1.ProviderReference{Name: route53v1alpha1.ProviderName, Version: route53v1alpha1.ProviderVersion}, - Zone: dnsv1alpha1.ZoneUnitZoneSpec{ - Ref: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: zoneName}, - ObservedGeneration: 1, - DomainName: "apps.example.com", - ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: zoneClassNamespace, Name: zoneClassName}, - }, - }, - } -} - -func markRoute53ZoneUnitDeleting(unit *dnsv1alpha1.ZoneUnit, deletionTime metav1.Time) { - unit.DeletionTimestamp = &deletionTime - if !slices.Contains(unit.Finalizers, ZoneFinalizer) { - unit.Finalizers = append(unit.Finalizers, ZoneFinalizer) - } -} - -func route53ZoneUnitWithRecordSetItem(zoneNamespace, zoneName, recordSetNamespace, recordSetName, recordName string, recordType dnsv1alpha1.RecordType) *dnsv1alpha1.ZoneUnit { - return &dnsv1alpha1.ZoneUnit{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: zoneNamespace, - Name: zoneName, - UID: types.UID("11111111-2222-3333-4444-555555555555"), - }, - Spec: dnsv1alpha1.ZoneUnitSpec{ - Provider: dnsv1alpha1.ProviderReference{Name: route53v1alpha1.ProviderName, Version: route53v1alpha1.ProviderVersion}, - Zone: dnsv1alpha1.ZoneUnitZoneSpec{ - Ref: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: zoneName}, - ObservedGeneration: 1, - DomainName: "apps.example.com", - ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: "route53-public"}, - }, - RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ - { - RecordSetNamespace: recordSetNamespace, - RecordSetName: recordSetName, - Name: recordName, - Type: recordType, - }, - }, - }, - } -} - -func route53ZoneUnitRecordSetSpec(recordSet *dnsv1alpha1.RecordSet) dnsv1alpha1.ZoneUnitRecordSetSpec { - return dnsv1alpha1.ZoneUnitRecordSetSpec{ - RecordSetNamespace: recordSet.Namespace, - RecordSetName: recordSet.Name, - ObservedGeneration: recordSet.Generation, - Name: recordSet.Spec.Name, - Type: recordSet.Spec.Type, - TTL: recordSet.Spec.TTL, - A: recordSet.Spec.A, - AAAA: recordSet.Spec.AAAA, - TXT: recordSet.Spec.TXT, - CNAME: recordSet.Spec.CNAME, - MX: recordSet.Spec.MX, - CAA: recordSet.Spec.CAA, - NS: recordSet.Spec.NS, - Options: recordSet.Spec.Options, - Adoption: recordSet.Spec.Adoption, - DeletionRequested: !recordSet.DeletionTimestamp.IsZero(), - } -} - -func route53ReadyZone(t *testing.T, namespace, name string) *dnsv1alpha1.Zone { - t.Helper() - zone := &dnsv1alpha1.Zone{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: name, - UID: types.UID("11111111-2222-3333-4444-555555555555"), - Generation: 1, - }, - Spec: dnsv1alpha1.ZoneSpec{ - DomainName: "apps.example.com", - ZoneClassRef: dnsv1alpha1.ZoneClassReference{Name: "route53-public"}, - Provider: route53v1alpha1.ProviderRef, - }, - Status: dnsv1alpha1.ZoneStatus{ - Conditions: []metav1.Condition{ - { - Type: string(dnsv1alpha1.ConditionAccepted), - Status: metav1.ConditionTrue, - Reason: "Accepted", - ObservedGeneration: 1, - }, - { - Type: string(dnsv1alpha1.ConditionProgrammed), - Status: metav1.ConditionTrue, - Reason: "Programmed", - ObservedGeneration: 1, - }, - }, - }, - } - setZoneStatusData(t, zone, route53v1alpha1.Route53ZoneStatusData{HostedZoneID: "Z000001"}) - return zone -} - -func route53ARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeA, - Name: "www", - TTL: ptr(int32(300)), - A: &dnsv1alpha1.ARecordSet{ - Addresses: []string{"192.0.2.10"}, - }, - }, - } -} - -func route53AAAARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeAAAA, - Name: "www", - TTL: ptr(int32(300)), - AAAA: &dnsv1alpha1.AAAARecordSet{ - Addresses: []string{"2001:db8::10"}, - }, - }, - } -} - -func route53TXTRecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeTXT, - Name: "_acme-challenge", - TTL: ptr(int32(300)), - TXT: &dnsv1alpha1.TXTRecordSet{ - Values: []string{"challenge-token", "v=spf1 include:_spf.example.net ~all"}, - }, - }, - } -} - -func route53CNAMERecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeCNAME, - Name: "www", - TTL: ptr(int32(300)), - CNAME: &dnsv1alpha1.CNAMERecordSet{ - Target: "target.example.net", - }, - }, - } -} - -func route53MXRecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeMX, - Name: "@", - TTL: ptr(int32(300)), - MX: &dnsv1alpha1.MXRecordSet{ - Records: []dnsv1alpha1.MXRecord{ - {Preference: 10, Exchange: "mail1.example.net"}, - {Preference: 20, Exchange: "mail2.example.net"}, - }, - }, - }, - } -} - -func route53CAARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeCAA, - Name: "@", - TTL: ptr(int32(300)), - CAA: &dnsv1alpha1.CAARecordSet{ - Records: []dnsv1alpha1.CAARecord{ - {Flags: 0, Tag: "issue", Value: "letsencrypt.org"}, - {Flags: 0, Tag: "iodef", Value: "mailto:security@example.com"}, - }, - }, - }, - } -} - -func route53NSRecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeNS, - Name: "delegated", - TTL: ptr(int32(300)), - NS: &dnsv1alpha1.NSRecordSet{ - NameServers: []string{"ns-111.example-dns.net", "ns-222.example-dns.net"}, - }, - }, - } -} - -func route53AliasARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { - return &dnsv1alpha1.RecordSet{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: dnsv1alpha1.RecordSetSpec{ - ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, - Provider: route53v1alpha1.ProviderRef, - Type: dnsv1alpha1.RecordTypeA, - Name: "alias", - Options: runtime.RawExtension{Raw: []byte(`{"alias":{"dnsName":"target.apps.example.com.","hostedZoneID":"Z000001","evaluateTargetHealth":false}}`)}, - }, - } -} - -func deletingRoute53ARecordSet(t *testing.T) *dnsv1alpha1.RecordSet { - t.Helper() - recordSet := route53ARecordSet("app", "www") - deletionTime := metav1.Now() - recordSet.DeletionTimestamp = &deletionTime - recordSet.Finalizers = []string{RecordSetFinalizer} - return recordSet -} - -func setZoneStatusData(t *testing.T, zone *dnsv1alpha1.Zone, data route53v1alpha1.Route53ZoneStatusData) { - t.Helper() - stateRaw, err := json.Marshal(data) - if err != nil { - t.Fatalf("json.Marshal returned error: %v", err) - } - publicData := route53v1alpha1.Route53ZoneStatusData{} - if data.HostedZoneID != "" { - publicData.HostedZoneID = data.HostedZoneID - } - publicRaw, err := json.Marshal(publicData) - if err != nil { - t.Fatalf("json.Marshal returned error: %v", err) - } - zone.Status.Provider = &dnsv1alpha1.ProviderStatus{ - Data: runtime.RawExtension{Raw: publicRaw}, - State: runtime.RawExtension{Raw: stateRaw}, - } -} - -func setRecordSetStatusData(t *testing.T, recordSet *dnsv1alpha1.RecordSet, data route53v1alpha1.Route53RecordSetStatusData) { - t.Helper() - raw, err := json.Marshal(data) - if err != nil { - t.Fatalf("json.Marshal returned error: %v", err) - } - recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: raw}} -} - -func mustZoneUnitRecordSetStatusState(t *testing.T, ctx context.Context, k8sClient client.Client, zoneNamespace, zoneName, recordSetNamespace, recordSetName string) route53v1alpha1.Route53RecordSetStatusData { - t.Helper() - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}, &unit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { - return status.RecordSetNamespace == recordSetNamespace && status.RecordSetName == recordSetName - }) - if index < 0 { - t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSetNamespace, recordSetName, unit.Status.RecordSets) - } - provider := unit.Status.RecordSets[index].Provider - if provider == nil || len(provider.State.Raw) == 0 { - t.Fatalf("ZoneUnit status.recordSets[%d].provider.state is empty", index) - } - var data route53v1alpha1.Route53RecordSetStatusData - if err := json.Unmarshal(provider.State.Raw, &data); err != nil { - t.Fatalf("ZoneUnit status.recordSets[%d].provider.state must match Route 53 schema: %v", index, err) - } - return data -} - -func acceptedRoute53Identity(namespace, name string) *route53v1alpha1.Route53Identity { - return &route53v1alpha1.Route53Identity{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, - Spec: route53v1alpha1.Route53IdentitySpec{ - AccountID: "123456789012", - Region: "ap-northeast-1", - Credentials: route53v1alpha1.Route53Credentials{ - Runtime: &route53v1alpha1.Route53RuntimeCredentials{}, - }, - }, - Status: route53v1alpha1.Route53IdentityStatus{ - Conditions: []metav1.Condition{ - { - Type: string(dnsv1alpha1.ConditionAccepted), - Status: metav1.ConditionTrue, - Reason: "Accepted", - ObservedGeneration: 1, - }, - { - Type: "Ready", - Status: metav1.ConditionTrue, - Reason: "Ready", - ObservedGeneration: 1, - }, - }, - }, - } -} - -func assertCondition(t *testing.T, conditions []metav1.Condition, conditionType string, status metav1.ConditionStatus, reason string) metav1.Condition { - t.Helper() - condition := meta.FindStatusCondition(conditions, conditionType) - if condition == nil { - t.Fatalf("condition %s was not found in %#v", conditionType, conditions) - } - if condition.Status != status || condition.Reason != reason { - t.Fatalf("condition %s = (%s, %s), want (%s, %s)", conditionType, condition.Status, condition.Reason, status, reason) - } - return *condition -} - -func mustZoneStatusData(t *testing.T, zone *dnsv1alpha1.Zone) route53v1alpha1.Route53ZoneStatusData { - t.Helper() - data, err := route53ZoneStatusData(zone) - if err != nil { - t.Fatalf("route53ZoneStatusData returned error: %v", err) - } - return data -} - -func mustZoneUnitStatusData(t *testing.T, ctx context.Context, k8sClient client.Client, namespace, name string) route53v1alpha1.Route53ZoneStatusData { - t.Helper() - var unit dnsv1alpha1.ZoneUnit - if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &unit); err != nil { - t.Fatalf("ZoneUnit was not found: %v", err) - } - data, err := route53ZoneUnitStatusData(&unit) - if err != nil { - t.Fatalf("route53ZoneUnitStatusData returned error: %v", err) - } - return data -} - -func ptr[T any](value T) *T { - return &value -} - -type recordedEvent struct { - Object string - Reason string -} - -type capturingRecorder struct { - events []recordedEvent -} - -func (r *capturingRecorder) Event(object runtime.Object, _ string, reason, _ string) { - r.events = append(r.events, recordedEvent{Object: eventObjectName(object), Reason: reason}) -} - -func (r *capturingRecorder) Eventf(object runtime.Object, eventType, reason, messageFmt string, args ...interface{}) { - r.Event(object, eventType, reason, fmt.Sprintf(messageFmt, args...)) -} - -func (r *capturingRecorder) AnnotatedEventf(object runtime.Object, _ map[string]string, eventType, reason, messageFmt string, args ...interface{}) { - r.Event(object, eventType, reason, fmt.Sprintf(messageFmt, args...)) -} - -func (r *capturingRecorder) has(object, reason string) bool { - for _, event := range r.events { - if event.Object == object && event.Reason == reason { - return true - } - } - return false -} - -func eventObjectName(object runtime.Object) string { - switch object.(type) { - case *dnsv1alpha1.RecordSet: - return "RecordSet" - case *dnsv1alpha1.Zone: - return "Zone" - case *route53v1alpha1.Route53Identity: - return "Route53Identity" - default: - return fmt.Sprintf("%T", object) - } -} - -type createHostedZoneRequest struct { - domainName string - callerReference string -} - -type fakeProvider struct { - zones map[string]HostedZone - tags map[string]map[string]string - changes map[string]*route53v1alpha1.Route53Change - records map[string]RecordSetResource - created []createHostedZoneRequest - upserted []RecordSetResource - deletedRRs []RecordSetResource - - changeRecordSetsErr func(hostedZoneID string, changes []RecordSetChange) error -} - -func newFakeProvider() *fakeProvider { - return &fakeProvider{ - zones: map[string]HostedZone{}, - tags: map[string]map[string]string{}, - changes: map[string]*route53v1alpha1.Route53Change{}, - records: map[string]RecordSetResource{}, - } -} - -func (p *fakeProvider) GetHostedZone(_ context.Context, id string) (HostedZone, error) { - zone, ok := p.zones[normalizeHostedZoneID(id)] - if !ok { - return HostedZone{}, &smithy.GenericAPIError{ - Code: "NoSuchHostedZone", - Message: fmt.Sprintf("hosted zone %s was not found", id), - } - } - return zone, nil -} - -func (p *fakeProvider) ListHostedZonesByName(_ context.Context, domainName string) ([]HostedZone, error) { - var zones []HostedZone - for _, zone := range p.zones { - if normalizeDomainName(zone.Name) == domainName { - zones = append(zones, zone) - } - } - return zones, nil -} - -func (p *fakeProvider) CreateHostedZone(_ context.Context, domainName, callerReference string) (CreatedHostedZone, error) { - p.created = append(p.created, createHostedZoneRequest{domainName: domainName, callerReference: callerReference}) - id := fmt.Sprintf("Z%06d", len(p.created)) - changeID := fmt.Sprintf("C%06d", len(p.created)) - hostedZone := HostedZone{ - ID: id, - Name: domainName, - CallerReference: callerReference, - NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, - } - p.zones[id] = hostedZone - p.changes[changeID] = &route53v1alpha1.Route53Change{ - ID: changeID, - Status: route53v1alpha1.Route53ChangeStatusPending, - } - return CreatedHostedZone{ - HostedZone: hostedZone, - Change: p.changes[changeID], - }, nil -} - -func (p *fakeProvider) GetChange(_ context.Context, id string) (*route53v1alpha1.Route53Change, error) { - change, ok := p.changes[id] - if !ok { - return nil, fmt.Errorf("change %s was not found", id) - } - return change, nil -} - -func (p *fakeProvider) DeleteHostedZone(_ context.Context, id string) (*route53v1alpha1.Route53Change, error) { - delete(p.zones, normalizeHostedZoneID(id)) - change := &route53v1alpha1.Route53Change{ - ID: "CDELETE", - Status: route53v1alpha1.Route53ChangeStatusPending, - } - p.changes[change.ID] = change - return change, nil -} - -func (p *fakeProvider) TagHostedZone(_ context.Context, id string, tags map[string]string) error { - copied := make(map[string]string, len(tags)) - for key, value := range tags { - copied[key] = value - } - p.tags[normalizeHostedZoneID(id)] = copied - return nil -} - -func (p *fakeProvider) GetRecordSet(_ context.Context, hostedZoneID, recordName string, recordType dnsv1alpha1.RecordType) (*RecordSetResource, error) { - record, ok := p.records[recordKey(hostedZoneID, recordName, recordType)] - if !ok { - return nil, nil - } - copied := copyRecordSetResource(record) - return &copied, nil -} - -func (p *fakeProvider) ListRecordSets(_ context.Context, hostedZoneID string) ([]RecordSetResource, error) { - var records []RecordSetResource - for _, record := range p.records { - if normalizeHostedZoneID(record.HostedZoneID) != normalizeHostedZoneID(hostedZoneID) { - continue - } - records = append(records, copyRecordSetResource(record)) - } - return records, nil -} - -func (p *fakeProvider) ChangeRecordSets(_ context.Context, hostedZoneID string, changes []RecordSetChange) (*route53v1alpha1.Route53Change, error) { - if p.changeRecordSetsErr != nil { - if err := p.changeRecordSetsErr(hostedZoneID, changes); err != nil { - return nil, err - } - } - for _, change := range changes { - recordSet := copyRecordSetResource(change.RecordSet) - recordSet.HostedZoneID = normalizeHostedZoneID(hostedZoneID) - switch change.Action { - case RecordSetChangeActionDelete: - p.deletedRRs = append(p.deletedRRs, recordSet) - delete(p.records, recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)) - default: - p.upserted = append(p.upserted, recordSet) - p.records[recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)] = recordSet - } - } - changeID := fmt.Sprintf("CRR%06d", len(p.upserted)+len(p.deletedRRs)) - p.changes[changeID] = &route53v1alpha1.Route53Change{ID: changeID, Status: route53v1alpha1.Route53ChangeStatusPending} - return p.changes[changeID], nil -} - -func (p *fakeProvider) UpsertRecordSet(_ context.Context, recordSet RecordSetResource) (*route53v1alpha1.Route53Change, error) { - copied := copyRecordSetResource(recordSet) - p.upserted = append(p.upserted, copied) - p.records[recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)] = copied - changeID := fmt.Sprintf("CRR%06d", len(p.upserted)) - p.changes[changeID] = &route53v1alpha1.Route53Change{ID: changeID, Status: route53v1alpha1.Route53ChangeStatusPending} - return p.changes[changeID], nil -} - -func (p *fakeProvider) DeleteRecordSet(_ context.Context, recordSet RecordSetResource) (*route53v1alpha1.Route53Change, error) { - copied := copyRecordSetResource(recordSet) - p.deletedRRs = append(p.deletedRRs, copied) - delete(p.records, recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)) - p.changes["CRRDELETE"] = &route53v1alpha1.Route53Change{ID: "CRRDELETE", Status: route53v1alpha1.Route53ChangeStatusPending} - return p.changes["CRRDELETE"], nil -} - -func recordKey(hostedZoneID, recordName string, recordType dnsv1alpha1.RecordType) string { - return normalizeHostedZoneID(hostedZoneID) + "|" + normalizeRoute53RecordName(recordName) + "|" + string(recordType) -} - -func copyRecordSetResource(recordSet RecordSetResource) RecordSetResource { - copied := recordSet - copied.Values = append([]string(nil), recordSet.Values...) - if recordSet.TTL != nil { - ttl := *recordSet.TTL - copied.TTL = &ttl - } - if recordSet.Alias != nil { - alias := *recordSet.Alias - copied.Alias = &alias - } - return copied -} diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go new file mode 100644 index 0000000..86a82f9 --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go @@ -0,0 +1,695 @@ +package route53 + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + "github.com/aws/smithy-go" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("corev1 AddToScheme: %v", err) + } + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("dns AddToScheme: %v", err) + } + if err := route53v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("route53 AddToScheme: %v", err) + } + return scheme +} +func sameNamespaceClassObjects(namespace string, tags map[string]string) []client.Object { + return []client.Object{ + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, + route53Provider(), + route53ZoneClass(namespace, "route53-public", tags), + acceptedRoute53Identity(namespace, "route53-dev"), + route53ZoneUnit(namespace, "apps-example-com", namespace, "route53-public"), + &dnsv1alpha1.Zone{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "apps-example-com", + UID: types.UID("11111111-2222-3333-4444-555555555555"), + Generation: 1, + }, + Spec: dnsv1alpha1.ZoneSpec{ + DomainName: "apps.example.com", + ZoneClassRef: dnsv1alpha1.ZoneClassReference{Name: "route53-public"}, + Provider: route53v1alpha1.ProviderRef, + }, + }, + } +} +func route53ZoneClass(namespace, name string, tags map[string]string) *dnsv1alpha1.ZoneClass { + parameters := route53v1alpha1.Route53ZoneClassParameters{ + Tags: tags, + } + raw, err := json.Marshal(parameters) + if err != nil { + panic(err) + } + return &dnsv1alpha1.ZoneClass{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, + Spec: dnsv1alpha1.ZoneClassSpec{ + Provider: route53v1alpha1.ProviderRef, + ControllerName: DefaultControllerName, + IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, + Parameters: runtime.RawExtension{Raw: raw}, + AllowedZones: dnsv1alpha1.ZoneClassAllowedZones{ + Namespaces: dnsv1alpha1.NamespacePolicy{From: dnsv1alpha1.NamespacesFromSame}, + }, + }, + Status: dnsv1alpha1.ZoneClassStatus{ + Conditions: []metav1.Condition{ + { + Type: string(dnsv1alpha1.ConditionAccepted), + Status: metav1.ConditionTrue, + Reason: "Accepted", + ObservedGeneration: 1, + }, + }, + }, + } +} +func route53Provider() *dnsv1alpha1.Provider { + return &dnsv1alpha1.Provider{ + ObjectMeta: metav1.ObjectMeta{Name: route53v1alpha1.ProviderName}, + Spec: dnsv1alpha1.ProviderSpec{ + Display: dnsv1alpha1.ProviderDisplay{Name: "Amazon Route 53"}, + Versions: []dnsv1alpha1.ProviderVersion{ + { + Name: route53v1alpha1.ProviderVersion, + Served: true, + Storage: true, + Identity: &dnsv1alpha1.ProviderIdentity{ + Resource: dnsv1alpha1.ProviderIdentityResource{ + Group: "route53.dns.appthrust.io", + Kind: "Route53Identity", + Scope: "Namespaced", + }, + }, + RecordSet: dnsv1alpha1.ProviderRecordSet{ + SupportedTypes: []dnsv1alpha1.RecordType{ + dnsv1alpha1.RecordTypeA, + dnsv1alpha1.RecordTypeAAAA, + dnsv1alpha1.RecordTypeTXT, + dnsv1alpha1.RecordTypeCNAME, + dnsv1alpha1.RecordTypeMX, + dnsv1alpha1.RecordTypeCAA, + dnsv1alpha1.RecordTypeNS, + }, + }, + }, + }, + }, + } +} +func route53RecordSetObjects(t *testing.T, recordSet *dnsv1alpha1.RecordSet) []client.Object { + t.Helper() + zone := route53ReadyZone(t, "app", "apps-example-com") + unit := route53ZoneUnit("app", "apps-example-com", "app", "route53-public") + unit.Spec.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetSpec{route53ZoneUnitRecordSetSpec(recordSet)} + if recordSet.Status.Provider != nil || len(recordSet.Status.Conditions) > 0 { + unit.Status.RecordSets = []dnsv1alpha1.ZoneUnitRecordSetStatus{ + { + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Status.ObservedGeneration, + Provider: recordSet.Status.Provider, + Conditions: slices.Clone(recordSet.Status.Conditions), + }, + } + } + return []client.Object{ + route53Provider(), + route53ZoneClass("app", "route53-public", nil), + acceptedRoute53Identity("app", "route53-dev"), + zone, + unit, + recordSet, + } +} +func route53ZoneUnit(zoneNamespace, zoneName, zoneClassNamespace, zoneClassName string) *dnsv1alpha1.ZoneUnit { + return &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: zoneNamespace, + Name: zoneName, + UID: types.UID("11111111-2222-3333-4444-555555555555"), + }, + Spec: dnsv1alpha1.ZoneUnitSpec{ + Provider: dnsv1alpha1.ProviderReference{Name: route53v1alpha1.ProviderName, Version: route53v1alpha1.ProviderVersion}, + Zone: dnsv1alpha1.ZoneUnitZoneSpec{ + Ref: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: zoneName}, + ObservedGeneration: 1, + DomainName: "apps.example.com", + ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: zoneClassNamespace, Name: zoneClassName}, + }, + }, + } +} +func markRoute53ZoneUnitDeleting(unit *dnsv1alpha1.ZoneUnit, deletionTime metav1.Time) { + unit.DeletionTimestamp = &deletionTime + if !slices.Contains(unit.Finalizers, ZoneFinalizer) { + unit.Finalizers = append(unit.Finalizers, ZoneFinalizer) + } +} +func route53ZoneUnitWithRecordSetItem(zoneNamespace, zoneName, recordSetNamespace, recordSetName, recordName string, recordType dnsv1alpha1.RecordType) *dnsv1alpha1.ZoneUnit { + return &dnsv1alpha1.ZoneUnit{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: zoneNamespace, + Name: zoneName, + UID: types.UID("11111111-2222-3333-4444-555555555555"), + }, + Spec: dnsv1alpha1.ZoneUnitSpec{ + Provider: dnsv1alpha1.ProviderReference{Name: route53v1alpha1.ProviderName, Version: route53v1alpha1.ProviderVersion}, + Zone: dnsv1alpha1.ZoneUnitZoneSpec{ + Ref: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: zoneName}, + ObservedGeneration: 1, + DomainName: "apps.example.com", + ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: zoneNamespace, Name: "route53-public"}, + }, + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + { + RecordSetNamespace: recordSetNamespace, + RecordSetName: recordSetName, + RecordSetUID: route53TestRecordSetUID(recordSetNamespace, recordSetName), + Name: recordName, + Type: recordType, + }, + }, + }, + } +} +func route53ZoneUnitRecordSetSpec(recordSet *dnsv1alpha1.RecordSet) dnsv1alpha1.ZoneUnitRecordSetSpec { + return dnsv1alpha1.ZoneUnitRecordSetSpec{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: recordSet.Generation, + Name: recordSet.Spec.Name, + Type: recordSet.Spec.Type, + TTL: recordSet.Spec.TTL, + A: recordSet.Spec.A, + AAAA: recordSet.Spec.AAAA, + TXT: recordSet.Spec.TXT, + CNAME: recordSet.Spec.CNAME, + MX: recordSet.Spec.MX, + CAA: recordSet.Spec.CAA, + NS: recordSet.Spec.NS, + Options: recordSet.Spec.Options, + Adoption: recordSet.Spec.Adoption, + DeletionRequested: !recordSet.DeletionTimestamp.IsZero(), + } +} +func route53ReadyZone(t *testing.T, namespace, name string) *dnsv1alpha1.Zone { + t.Helper() + zone := &dnsv1alpha1.Zone{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + UID: types.UID("11111111-2222-3333-4444-555555555555"), + Generation: 1, + }, + Spec: dnsv1alpha1.ZoneSpec{ + DomainName: "apps.example.com", + ZoneClassRef: dnsv1alpha1.ZoneClassReference{Name: "route53-public"}, + Provider: route53v1alpha1.ProviderRef, + }, + Status: dnsv1alpha1.ZoneStatus{ + Conditions: []metav1.Condition{ + { + Type: string(dnsv1alpha1.ConditionAccepted), + Status: metav1.ConditionTrue, + Reason: "Accepted", + ObservedGeneration: 1, + }, + { + Type: string(dnsv1alpha1.ConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: "Programmed", + ObservedGeneration: 1, + }, + }, + }, + } + setZoneStatusData(t, zone, route53v1alpha1.Route53ZoneStatusData{HostedZoneID: "Z000001"}) + return zone +} +func route53TestRecordSetUID(namespace, name string) types.UID { + return types.UID("test-route53-recordset-" + namespace + "-" + name) +} + +func route53ARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeA, + Name: "www", + TTL: ptr(int32(300)), + A: &dnsv1alpha1.ARecordSet{ + Addresses: []string{"192.0.2.10"}, + }, + }, + } +} +func route53AAAARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeAAAA, + Name: "www", + TTL: ptr(int32(300)), + AAAA: &dnsv1alpha1.AAAARecordSet{ + Addresses: []string{"2001:db8::10"}, + }, + }, + } +} +func route53TXTRecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeTXT, + Name: "_acme-challenge", + TTL: ptr(int32(300)), + TXT: &dnsv1alpha1.TXTRecordSet{ + Values: []string{"challenge-token", "v=spf1 include:_spf.example.net ~all"}, + }, + }, + } +} +func route53CNAMERecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeCNAME, + Name: "www", + TTL: ptr(int32(300)), + CNAME: &dnsv1alpha1.CNAMERecordSet{ + Target: "target.example.net", + }, + }, + } +} +func route53MXRecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeMX, + Name: "@", + TTL: ptr(int32(300)), + MX: &dnsv1alpha1.MXRecordSet{ + Records: []dnsv1alpha1.MXRecord{ + {Preference: 10, Exchange: "mail1.example.net"}, + {Preference: 20, Exchange: "mail2.example.net"}, + }, + }, + }, + } +} +func route53CAARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeCAA, + Name: "@", + TTL: ptr(int32(300)), + CAA: &dnsv1alpha1.CAARecordSet{ + Records: []dnsv1alpha1.CAARecord{ + {Flags: 0, Tag: "issue", Value: "letsencrypt.org"}, + {Flags: 0, Tag: "iodef", Value: "mailto:security@example.com"}, + }, + }, + }, + } +} +func route53NSRecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeNS, + Name: "delegated", + TTL: ptr(int32(300)), + NS: &dnsv1alpha1.NSRecordSet{ + NameServers: []string{"ns-111.example-dns.net", "ns-222.example-dns.net"}, + }, + }, + } +} +func route53AliasARecordSet(namespace, name string) *dnsv1alpha1.RecordSet { + return &dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: route53TestRecordSetUID(namespace, name), Generation: 1}, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Name: "apps-example-com"}, + Provider: route53v1alpha1.ProviderRef, + Type: dnsv1alpha1.RecordTypeA, + Name: "alias", + Options: runtime.RawExtension{Raw: []byte(`{"alias":{"dnsName":"target.apps.example.com.","hostedZoneID":"Z000001","evaluateTargetHealth":false}}`)}, + }, + } +} +func deletingRoute53ARecordSet(t *testing.T) *dnsv1alpha1.RecordSet { + t.Helper() + recordSet := route53ARecordSet("app", "www") + deletionTime := metav1.Now() + recordSet.DeletionTimestamp = &deletionTime + recordSet.Finalizers = []string{RecordSetFinalizer} + return recordSet +} +func setZoneStatusData(t *testing.T, zone *dnsv1alpha1.Zone, data route53v1alpha1.Route53ZoneStatusData) { + t.Helper() + stateRaw, err := json.Marshal(data) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + publicData := route53v1alpha1.Route53ZoneStatusData{} + if data.HostedZoneID != "" { + publicData.HostedZoneID = data.HostedZoneID + } + publicRaw, err := json.Marshal(publicData) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + zone.Status.Provider = &dnsv1alpha1.ProviderStatus{ + Data: runtime.RawExtension{Raw: publicRaw}, + State: runtime.RawExtension{Raw: stateRaw}, + } +} +func setRecordSetStatusData(t *testing.T, recordSet *dnsv1alpha1.RecordSet, data route53v1alpha1.Route53RecordSetStatusData) { + t.Helper() + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + recordSet.Status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: raw}} +} +func mustZoneUnitRecordSetStatusState(t *testing.T, ctx context.Context, k8sClient client.Client, zoneNamespace, zoneName, recordSetNamespace, recordSetName string) route53v1alpha1.Route53RecordSetStatusData { + t.Helper() + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}, &unit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + index := slices.IndexFunc(unit.Status.RecordSets, func(status dnsv1alpha1.ZoneUnitRecordSetStatus) bool { + return status.RecordSetNamespace == recordSetNamespace && status.RecordSetName == recordSetName + }) + if index < 0 { + t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSetNamespace, recordSetName, unit.Status.RecordSets) + } + provider := unit.Status.RecordSets[index].Provider + if provider == nil || len(provider.State.Raw) == 0 { + t.Fatalf("ZoneUnit status.recordSets[%d].provider.state is empty", index) + } + var data route53v1alpha1.Route53RecordSetStatusData + if err := json.Unmarshal(provider.State.Raw, &data); err != nil { + t.Fatalf("ZoneUnit status.recordSets[%d].provider.state must match Route 53 schema: %v", index, err) + } + return data +} +func acceptedRoute53Identity(namespace, name string) *route53v1alpha1.Route53Identity { + return &route53v1alpha1.Route53Identity{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Generation: 1}, + Spec: route53v1alpha1.Route53IdentitySpec{ + AccountID: "123456789012", + Region: "ap-northeast-1", + Credentials: route53v1alpha1.Route53Credentials{ + Runtime: &route53v1alpha1.Route53RuntimeCredentials{}, + }, + }, + Status: route53v1alpha1.Route53IdentityStatus{ + Conditions: []metav1.Condition{ + { + Type: string(dnsv1alpha1.ConditionAccepted), + Status: metav1.ConditionTrue, + Reason: "Accepted", + ObservedGeneration: 1, + }, + { + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Ready", + ObservedGeneration: 1, + }, + }, + }, + } +} +func assertCondition(t *testing.T, conditions []metav1.Condition, conditionType string, status metav1.ConditionStatus, reason string) metav1.Condition { + t.Helper() + condition := meta.FindStatusCondition(conditions, conditionType) + if condition == nil { + t.Fatalf("condition %s was not found in %#v", conditionType, conditions) + } + if condition.Status != status || condition.Reason != reason { + t.Fatalf("condition %s = (%s, %s), want (%s, %s)", conditionType, condition.Status, condition.Reason, status, reason) + } + return *condition +} +func mustZoneStatusData(t *testing.T, zone *dnsv1alpha1.Zone) route53v1alpha1.Route53ZoneStatusData { + t.Helper() + data, err := route53ZoneStatusData(zone) + if err != nil { + t.Fatalf("route53ZoneStatusData returned error: %v", err) + } + return data +} +func mustZoneUnitStatusData(t *testing.T, ctx context.Context, k8sClient client.Client, namespace, name string) route53v1alpha1.Route53ZoneStatusData { + t.Helper() + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &unit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + data, err := route53ZoneUnitStatusData(&unit) + if err != nil { + t.Fatalf("route53ZoneUnitStatusData returned error: %v", err) + } + return data +} +func ptr[T any](value T) *T { + return &value +} + +type recordedEvent struct { + Object string + Reason string +} +type capturingRecorder struct { + events []recordedEvent +} + +func (r *capturingRecorder) Event(object runtime.Object, _ string, reason, _ string) { + r.events = append(r.events, recordedEvent{Object: eventObjectName(object), Reason: reason}) +} +func (r *capturingRecorder) Eventf(object runtime.Object, eventType, reason, messageFmt string, args ...interface{}) { + r.Event(object, eventType, reason, fmt.Sprintf(messageFmt, args...)) +} +func (r *capturingRecorder) AnnotatedEventf(object runtime.Object, _ map[string]string, eventType, reason, messageFmt string, args ...interface{}) { + r.Event(object, eventType, reason, fmt.Sprintf(messageFmt, args...)) +} +func (r *capturingRecorder) has(object, reason string) bool { + for _, event := range r.events { + if event.Object == object && event.Reason == reason { + return true + } + } + return false +} +func eventObjectName(object runtime.Object) string { + switch object.(type) { + case *dnsv1alpha1.RecordSet: + return "RecordSet" + case *dnsv1alpha1.Zone: + return "Zone" + case *route53v1alpha1.Route53Identity: + return "Route53Identity" + default: + return fmt.Sprintf("%T", object) + } +} + +type createHostedZoneRequest struct { + domainName string + callerReference string +} +type fakeProvider struct { + zones map[string]HostedZone + tags map[string]map[string]string + changes map[string]*route53v1alpha1.Route53Change + records map[string]RecordSetResource + created []createHostedZoneRequest + upserted []RecordSetResource + deletedRRs []RecordSetResource + + changeRecordSetsErr func(hostedZoneID string, changes []RecordSetChange) error +} + +func newFakeProvider() *fakeProvider { + return &fakeProvider{ + zones: map[string]HostedZone{}, + tags: map[string]map[string]string{}, + changes: map[string]*route53v1alpha1.Route53Change{}, + records: map[string]RecordSetResource{}, + } +} +func (p *fakeProvider) GetHostedZone(_ context.Context, id string) (HostedZone, error) { + zone, ok := p.zones[normalizeHostedZoneID(id)] + if !ok { + return HostedZone{}, &smithy.GenericAPIError{ + Code: "NoSuchHostedZone", + Message: fmt.Sprintf("hosted zone %s was not found", id), + } + } + return zone, nil +} +func (p *fakeProvider) ListHostedZonesByName(_ context.Context, domainName string) ([]HostedZone, error) { + var zones []HostedZone + for _, zone := range p.zones { + if normalizeDomainName(zone.Name) == domainName { + zones = append(zones, zone) + } + } + return zones, nil +} +func (p *fakeProvider) CreateHostedZone(_ context.Context, domainName, callerReference string) (CreatedHostedZone, error) { + p.created = append(p.created, createHostedZoneRequest{domainName: domainName, callerReference: callerReference}) + id := fmt.Sprintf("Z%06d", len(p.created)) + changeID := fmt.Sprintf("C%06d", len(p.created)) + hostedZone := HostedZone{ + ID: id, + Name: domainName, + CallerReference: callerReference, + NameServers: []string{"ns-1.awsdns.example", "ns-2.awsdns.example"}, + } + p.zones[id] = hostedZone + p.changes[changeID] = &route53v1alpha1.Route53Change{ + ID: changeID, + Status: route53v1alpha1.Route53ChangeStatusPending, + } + return CreatedHostedZone{ + HostedZone: hostedZone, + Change: p.changes[changeID], + }, nil +} +func (p *fakeProvider) GetChange(_ context.Context, id string) (*route53v1alpha1.Route53Change, error) { + change, ok := p.changes[id] + if !ok { + return nil, fmt.Errorf("change %s was not found", id) + } + return change, nil +} +func (p *fakeProvider) DeleteHostedZone(_ context.Context, id string) (*route53v1alpha1.Route53Change, error) { + delete(p.zones, normalizeHostedZoneID(id)) + change := &route53v1alpha1.Route53Change{ + ID: "CDELETE", + Status: route53v1alpha1.Route53ChangeStatusPending, + } + p.changes[change.ID] = change + return change, nil +} +func (p *fakeProvider) TagHostedZone(_ context.Context, id string, tags map[string]string) error { + copied := make(map[string]string, len(tags)) + for key, value := range tags { + copied[key] = value + } + p.tags[normalizeHostedZoneID(id)] = copied + return nil +} +func (p *fakeProvider) GetRecordSet(_ context.Context, hostedZoneID, recordName string, recordType dnsv1alpha1.RecordType) (*RecordSetResource, error) { + record, ok := p.records[recordKey(hostedZoneID, recordName, recordType)] + if !ok { + return nil, nil + } + copied := copyRecordSetResource(record) + return &copied, nil +} +func (p *fakeProvider) ListRecordSets(_ context.Context, hostedZoneID string) ([]RecordSetResource, error) { + var records []RecordSetResource + for _, record := range p.records { + if normalizeHostedZoneID(record.HostedZoneID) != normalizeHostedZoneID(hostedZoneID) { + continue + } + records = append(records, copyRecordSetResource(record)) + } + return records, nil +} +func (p *fakeProvider) ChangeRecordSets(_ context.Context, hostedZoneID string, changes []RecordSetChange) (*route53v1alpha1.Route53Change, error) { + if p.changeRecordSetsErr != nil { + if err := p.changeRecordSetsErr(hostedZoneID, changes); err != nil { + return nil, err + } + } + for _, change := range changes { + recordSet := copyRecordSetResource(change.RecordSet) + recordSet.HostedZoneID = normalizeHostedZoneID(hostedZoneID) + switch change.Action { + case RecordSetChangeActionDelete: + p.deletedRRs = append(p.deletedRRs, recordSet) + delete(p.records, recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)) + default: + p.upserted = append(p.upserted, recordSet) + p.records[recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)] = recordSet + } + } + changeID := fmt.Sprintf("CRR%06d", len(p.upserted)+len(p.deletedRRs)) + p.changes[changeID] = &route53v1alpha1.Route53Change{ID: changeID, Status: route53v1alpha1.Route53ChangeStatusPending} + return p.changes[changeID], nil +} +func (p *fakeProvider) UpsertRecordSet(_ context.Context, recordSet RecordSetResource) (*route53v1alpha1.Route53Change, error) { + copied := copyRecordSetResource(recordSet) + p.upserted = append(p.upserted, copied) + p.records[recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)] = copied + changeID := fmt.Sprintf("CRR%06d", len(p.upserted)) + p.changes[changeID] = &route53v1alpha1.Route53Change{ID: changeID, Status: route53v1alpha1.Route53ChangeStatusPending} + return p.changes[changeID], nil +} +func (p *fakeProvider) DeleteRecordSet(_ context.Context, recordSet RecordSetResource) (*route53v1alpha1.Route53Change, error) { + copied := copyRecordSetResource(recordSet) + p.deletedRRs = append(p.deletedRRs, copied) + delete(p.records, recordKey(recordSet.HostedZoneID, recordSet.Name, recordSet.Type)) + p.changes["CRRDELETE"] = &route53v1alpha1.Route53Change{ID: "CRRDELETE", Status: route53v1alpha1.Route53ChangeStatusPending} + return p.changes["CRRDELETE"], nil +} +func recordKey(hostedZoneID, recordName string, recordType dnsv1alpha1.RecordType) string { + return normalizeHostedZoneID(hostedZoneID) + "|" + normalizeRoute53RecordName(recordName) + "|" + string(recordType) +} +func copyRecordSetResource(recordSet RecordSetResource) RecordSetResource { + copied := recordSet + copied.Values = append([]string(nil), recordSet.Values...) + if recordSet.TTL != nil { + ttl := *recordSet.TTL + copied.TTL = &ttl + } + if recordSet.Alias != nil { + alias := *recordSet.Alias + copied.Alias = &alias + } + return copied +} diff --git a/pkg/go/api/dns/v1alpha1/zoneunit_types.go b/pkg/go/api/dns/v1alpha1/zoneunit_types.go index 04ee81a..a83164a 100644 --- a/pkg/go/api/dns/v1alpha1/zoneunit_types.go +++ b/pkg/go/api/dns/v1alpha1/zoneunit_types.go @@ -3,6 +3,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ) // ZoneUnit is the zone-scoped desired-state and ownership ledger built from accepted Zone and RecordSet claims. @@ -63,6 +64,14 @@ type ZoneUnitRecordSetSpec struct { // RecordSetName is the owner RecordSet name. RecordSetName string `json:"recordSetName"` + // RecordSetUID binds this item to one claim incarnation. It is optional on + // the wire so pre-upgrade ledgers remain readable, not to authorize an + // unbound provider mutation. Composition supplies the current claim UID. + // +optional + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:MinLength=1 + RecordSetUID types.UID `json:"recordSetUID,omitempty"` + // ObservedGeneration is the RecordSet generation used to build this item. ObservedGeneration int64 `json:"observedGeneration,omitempty"` @@ -179,6 +188,14 @@ type ZoneUnitRecordSetStatus struct { // RecordSetName is the owner RecordSet name matching spec.recordSets[]. RecordSetName string `json:"recordSetName"` + // RecordSetUID identifies the claim incarnation observed by the provider. + // Empty or mismatched UIDs cannot authorize cleanup or transfer provider + // ownership to a recreated claim, even when name and generation match. + // +optional + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:MinLength=1 + RecordSetUID types.UID `json:"recordSetUID,omitempty"` + // ObservedGeneration is the owner RecordSet generation observed in ZoneUnit.spec. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` @@ -187,7 +204,8 @@ type ZoneUnitRecordSetStatus struct { // +optional Provider *ProviderStatus `json:"provider,omitempty"` - // DeletionCompleted is true after provider-side record cleanup has completed. + // DeletionCompleted is true after provider-side cleanup for RecordSetUID has + // completed. Consumers must match its nonempty UID to the current claim. // +optional DeletionCompleted bool `json:"deletionCompleted,omitempty"` diff --git a/pkg/go/api/route53/v1alpha1/route53zone_types.go b/pkg/go/api/route53/v1alpha1/route53zone_types.go index b9cddb3..71f7efa 100644 --- a/pkg/go/api/route53/v1alpha1/route53zone_types.go +++ b/pkg/go/api/route53/v1alpha1/route53zone_types.go @@ -1,6 +1,9 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) type Route53ChangeStatus string @@ -72,4 +75,9 @@ type Route53AffectedRecordSet struct { // Name is the RecordSet name. Name string `json:"name"` + + // UID binds completion to the claim that submitted this batch. Legacy + // UID-less changes may be polled, but cannot attest a current claim. + // +optional + UID types.UID `json:"uid,omitempty"` } diff --git a/pkg/ts/ui/src/resources.ts b/pkg/ts/ui/src/resources.ts index 2bbc6e1..677376f 100644 --- a/pkg/ts/ui/src/resources.ts +++ b/pkg/ts/ui/src/resources.ts @@ -169,6 +169,7 @@ export interface ZoneUnit extends KubeObjectInterface { export interface ZoneUnitRecordSetSpec { recordSetNamespace: string; recordSetName: string; + recordSetUID?: string; observedGeneration?: number; name: string; type: string; @@ -178,6 +179,7 @@ export interface ZoneUnitRecordSetSpec { export interface ZoneUnitRecordSetStatus { recordSetNamespace: string; recordSetName: string; + recordSetUID?: string; observedGeneration?: number; deletionCompleted?: boolean; provider?: { diff --git a/pkg/ts/ui/src/views/zones/RecordSetDetailPanels.tsx b/pkg/ts/ui/src/views/zones/RecordSetDetailPanels.tsx new file mode 100644 index 0000000..90b1899 --- /dev/null +++ b/pkg/ts/ui/src/views/zones/RecordSetDetailPanels.tsx @@ -0,0 +1,628 @@ +/** @jsxRuntime classic */ +import React from "react"; +import { Icon } from "../../components/Icon"; +import { YamlCodeBlock } from "../../components/YamlCodeBlock"; +import { Box, Stack, Typography } from "../../components/primitives"; +import { nameOf, namespaceOf, zoneClassIdentityName, zoneClassRefNamespace } from "../../resources"; +import type { ProviderIdentity, RecordSet, Zone, ZoneClass, ZoneUnit } from "../../types/resources"; +import { CopyFeedbackButton, DataGridTable, DesignLinkButton, eventsForResource, GridCell, GridHeader, GridRow, Panel, ResourceConditionsPanel, ResourceEventsPanel, StatusBadge, toYaml, ToolbarButton, ui } from "../common/ui"; +import { aliasOptions, conditionSummary, HealthInline, resourceConditionsHealth, ttlMode, type Health, type ZoneUnitOwnership } from "./recordSetPresentation"; + +export function RecordDataTab({ + fqdn, + provider, + recordSet, + recordValue, + onCopy, +}: { + fqdn: string; + provider: string; + recordSet: RecordSet; + recordValue: string; + onCopy: (value: string) => void; +}) { + const recordFields = desiredRecordRows(recordSet, recordValue); + const providerOptions = providerOptionRows(recordSet); + const providerStatus = providerStatusRows(recordSet); + + return ( + + + + + + Desired record + + + {fqdn} + + + onCopy(recordValue)} + /> + + + + {recordFields.map((row) => ( + + ))} + + + + {providerOptions.length ? ( + + ) : null} + + {providerStatus.length ? ( + + ) : null} + + {recordSet.spec.adoption ? ( + + + Adoption + + + + ) : null} + + ); +} + +export function StatusTab({ + events, + eventsError, + identity, + onOpenIdentity, + onOpenZone, + onOpenZoneClass, + recordSet, + zone, + zoneClass, + zoneUnit, +}: { + events: ReturnType; + eventsError: unknown; + identity?: ProviderIdentity; + onOpenIdentity?: () => void; + onOpenZone: () => void; + onOpenZoneClass?: () => void; + recordSet: RecordSet; + zone: Zone; + zoneClass?: ZoneClass; + zoneUnit?: ZoneUnit; +}) { + return ( + + + + + + + Provider status + + + + + ); +} + +function RelatedResourcesPanel({ + identity, + onOpenIdentity, + onOpenZone, + onOpenZoneClass, + zone, + zoneClass, + zoneUnit, +}: { + identity?: ProviderIdentity; + onOpenIdentity?: () => void; + onOpenZone: () => void; + onOpenZoneClass?: () => void; + zone: Zone; + zoneClass?: ZoneClass; + zoneUnit?: ZoneUnit; +}) { + const identityName = zoneClassIdentityName(zoneClass); + return ( + + + Related resources + + + + View + + } + /> + + View + + ) : undefined + } + /> + + View + + ) : undefined + } + /> + + + + ); +} + +function RelatedResourceRow({ + action, + divided, + health, + label, + reason, +}: { + action?: React.ReactNode; + divided?: boolean; + health: Health; + label: string; + reason: string; +}) { + return ( + + + {label} + + + + {reason} + + {action} + + ); +} + +export function OwnershipTab({ + fqdn, + ownership, + recordSet, + zone, + zoneUnit, +}: { + fqdn: string; + ownership: ZoneUnitOwnership; + recordSet: RecordSet; + zone: Zone; + zoneUnit?: ZoneUnit; +}) { + return ( + + + + Provider effect + + + + + + + {ownership.providerDeletion} + + + + + + + ); +} + +export function ManifestTab({ + liveYaml, + onCopy, + onEdit, +}: { + liveYaml: string; + onCopy: () => void; + onEdit: () => void; +}) { + return ( + + + + Manifest + + + + + + + + + ); +} + +type InfoRow = [string, React.ReactNode]; + +function InfoTablePanel({ title, rows }: { title: string; rows: InfoRow[] }) { + return ( + + + + {title} + + + + + {rows.map(([label, value]) => ( + + ))} + + + ); +} + +function RecordSetInfoRow({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + return ( + + + + {label} + + + + {typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" ? ( + + {String(value)} + + ) : ( + value + )} + + + ); +} + +function ValueList({ + values, +}: { + values: Array; +}) { + const present = values.filter((value) => value !== undefined && value !== ""); + if (!present.length) { + return -; + } + return ( + + {present.map((value, index) => ( + + {String(value)} + + ))} + + ); +} + +function MutedText({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function desiredRecordRows( + recordSet: RecordSet, + recordValue: string, +): Array<{ label: string; value: React.ReactNode }> { + const rows: Array<{ label: string; value: React.ReactNode }> = [ + { label: "Owner name", value: recordSet.spec.name }, + { label: "Type", value: recordSet.spec.type }, + { label: "TTL", value: recordSet.spec.ttl ?? ttlMode(recordSet) }, + ]; + const alias = aliasOptions(recordSet); + if (alias) { + return [ + ...rows, + { label: "Alias target DNS name", value: String(alias.dnsName ?? "-") }, + { + label: "Alias target hosted zone ID", + value: String(alias.hostedZoneID ?? "-"), + }, + { + label: "Evaluate target health", + value: alias.evaluateTargetHealth ? "true" : "false", + }, + ]; + } + if (recordSet.spec.a?.addresses?.length) { + rows.push({ + label: "IPv4 addresses", + value: , + }); + } else if (recordSet.spec.aaaa?.addresses?.length) { + rows.push({ + label: "IPv6 addresses", + value: , + }); + } else if (recordSet.spec.txt?.values?.length) { + rows.push({ + label: "TXT values", + value: , + }); + } else if (recordSet.spec.cname?.target) { + rows.push({ label: "CNAME target", value: recordSet.spec.cname.target }); + } else if (recordSet.spec.mx?.records?.length) { + rows.push({ + label: "MX records", + value: ( + + {recordSet.spec.mx.records.map((record, index) => ( + + {record.preference ?? "-"} {record.exchange ?? "-"} + + ))} + + ), + }); + } else if (recordSet.spec.caa?.records?.length) { + rows.push({ + label: "CAA records", + value: ( + + {recordSet.spec.caa.records.map((record, index) => ( + + {record.flags ?? "-"} {record.tag ?? "-"} {record.value ?? "-"} + + ))} + + ), + }); + } else if (recordSet.spec.ns?.nameServers?.length) { + rows.push({ + label: "Name servers", + value: , + }); + } else { + rows.push({ label: "Value", value: recordValue }); + } + return rows; +} + +function providerOptionRows(recordSet: RecordSet): InfoRow[] { + const options = recordSet.spec.options ?? {}; + const rows: InfoRow[] = []; + const alias = aliasOptions(recordSet); + if (alias) { + return rows; + } + if ("ttl" in options) { + rows.push(["TTL mode", String(options.ttl ?? "-")]); + } + if ("proxied" in options) { + rows.push([ + "Proxy status", + Boolean(options.proxied) ? "Proxied" : "DNS only", + ]); + } + if ("comment" in options) { + rows.push(["Comment", String(options.comment ?? "-")]); + } + if (Array.isArray(options.tags)) { + rows.push(["Tags", ]); + } + return rows; +} + +function providerStatusRows(recordSet: RecordSet): InfoRow[] { + const data = recordSet.status?.provider?.data; + if (!data) { + return []; + } + const rows: InfoRow[] = []; + const cloudflareRecords = (data as { records?: unknown }).records; + if (Array.isArray(cloudflareRecords) && cloudflareRecords.length) { + rows.push([ + "Cloudflare records", + , + ]); + } + return rows; +} diff --git a/pkg/ts/ui/src/views/zones/RecordSetPages.tsx b/pkg/ts/ui/src/views/zones/RecordSetPages.tsx index a65214e..e2db88c 100644 --- a/pkg/ts/ui/src/views/zones/RecordSetPages.tsx +++ b/pkg/ts/ui/src/views/zones/RecordSetPages.tsx @@ -1,6 +1,5 @@ /** @jsxRuntime classic */ import { Icon } from "../../components/Icon"; -import { YamlCodeBlock } from "../../components/YamlCodeBlock"; import { Box } from "../../components/primitives"; import { IconButton } from "../../components/primitives"; import { Stack } from "../../components/primitives"; @@ -12,33 +11,20 @@ import { fqdnForRecordSet, nameOf, namespaceOf, - zoneClassIdentityName, zoneClassRefNamespace, } from "../../resources"; import type { - DnsCondition, - ProviderIdentity, RecordSet, Zone, - ZoneClass, - ZoneUnit, - ZoneUnitRecordSetSpec, } from "../../types/resources"; import { type BreadcrumbItem, - CopyFeedbackButton, - DataGridTable, DetailFieldGrid, DesignLinkButton, DnsData, eventsForResource, - GridCell, - GridHeader, - GridRow, Page, Panel, - ResourceConditionsPanel, - ResourceEventsPanel, StatusBadge, tableHeaderSx, toYaml, @@ -48,9 +34,14 @@ import { } from "../common/ui"; import { integrationDetailPath, zoneClassDetailPath } from "../platform/routes"; import { zonePath } from "./routes"; +import { ManifestTab, OwnershipTab, RecordDataTab, StatusTab } from "./RecordSetDetailPanels"; +import { + findProviderIdentity, providerLabel, providerRefText, recordSetHealth, + SmallBadge, ttlMode, VitalsHealth, zoneUnitForZone, zoneUnitOwnership, + type Health, type ZoneUnitOwnership, +} from "./recordSetPresentation"; type RecordSetTab = "recordData" | "status" | "ownership" | "manifest"; -type Health = "Healthy" | "Syncing" | "Degraded"; const recordSetTabItems: Array<{ id: RecordSetTab; label: string }> = [ { id: "recordData", label: "Record data" }, @@ -59,9 +50,6 @@ const recordSetTabItems: Array<{ id: RecordSetTab; label: string }> = [ { id: "manifest", label: "Manifest" }, ]; -function providerRefText(provider?: RecordSet["spec"]["provider"]) { - return provider ? `${provider.name}/${provider.version}` : ""; -} export function recordSetValue(recordSet: RecordSet) { const spec = recordSet.spec as RecordSet["spec"] & { @@ -189,6 +177,7 @@ export function RecordSetDetailPage({ fqdn={fqdn} provider={provider} recordSet={recordSet} + recordValue={recordSetValue(recordSet)} onCopy={copyText} /> ) : null} @@ -578,865 +567,6 @@ function RecordSetTabs({ ); } -function RecordDataTab({ - fqdn, - provider, - recordSet, - onCopy, -}: { - fqdn: string; - provider: string; - recordSet: RecordSet; - onCopy: (value: string) => void; -}) { - const recordFields = desiredRecordRows(recordSet); - const providerOptions = providerOptionRows(recordSet); - const providerStatus = providerStatusRows(recordSet); - - return ( - - - - - - Desired record - - - {fqdn} - - - onCopy(recordSetValue(recordSet))} - /> - - - - {recordFields.map((row) => ( - - ))} - - - - {providerOptions.length ? ( - - ) : null} - - {providerStatus.length ? ( - - ) : null} - - {recordSet.spec.adoption ? ( - - - Adoption - - - - ) : null} - - ); -} - -function StatusTab({ - events, - eventsError, - identity, - onOpenIdentity, - onOpenZone, - onOpenZoneClass, - recordSet, - zone, - zoneClass, - zoneUnit, -}: { - events: ReturnType; - eventsError: unknown; - identity?: ProviderIdentity; - onOpenIdentity?: () => void; - onOpenZone: () => void; - onOpenZoneClass?: () => void; - recordSet: RecordSet; - zone: Zone; - zoneClass?: ZoneClass; - zoneUnit?: ZoneUnit; -}) { - return ( - - - - - - - Provider status - - - - - ); -} - -function RelatedResourcesPanel({ - identity, - onOpenIdentity, - onOpenZone, - onOpenZoneClass, - zone, - zoneClass, - zoneUnit, -}: { - identity?: ProviderIdentity; - onOpenIdentity?: () => void; - onOpenZone: () => void; - onOpenZoneClass?: () => void; - zone: Zone; - zoneClass?: ZoneClass; - zoneUnit?: ZoneUnit; -}) { - const identityName = zoneClassIdentityName(zoneClass); - return ( - - - Related resources - - - - View - - } - /> - - View - - ) : undefined - } - /> - - View - - ) : undefined - } - /> - - - - ); -} - -function RelatedResourceRow({ - action, - divided, - health, - label, - reason, -}: { - action?: React.ReactNode; - divided?: boolean; - health: Health; - label: string; - reason: string; -}) { - return ( - - - {label} - - - - {reason} - - {action} - - ); -} - -function OwnershipTab({ - fqdn, - ownership, - recordSet, - zone, - zoneUnit, -}: { - fqdn: string; - ownership: ZoneUnitOwnership; - recordSet: RecordSet; - zone: Zone; - zoneUnit?: ZoneUnit; -}) { - return ( - - - - Provider effect - - - - - - - {ownership.providerDeletion} - - - - - - - ); -} - -function ManifestTab({ - liveYaml, - onCopy, - onEdit, -}: { - liveYaml: string; - onCopy: () => void; - onEdit: () => void; -}) { - return ( - - - - Manifest - - - - - - - - - ); -} - -type InfoRow = [string, React.ReactNode]; - -type ZoneUnitOwnership = { - ownerItem?: ZoneUnitRecordSetSpec; - ownsItem: boolean; - label: string; - ownerLabel: string; - providerDeletion: string; -}; - -function InfoTablePanel({ title, rows }: { title: string; rows: InfoRow[] }) { - return ( - - - - {title} - - - - - {rows.map(([label, value]) => ( - - ))} - - - ); -} - -function RecordSetInfoRow({ - label, - value, -}: { - label: string; - value: React.ReactNode; -}) { - return ( - - - - {label} - - - - {typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" ? ( - - {String(value)} - - ) : ( - value - )} - - - ); -} - -function ValueList({ - values, -}: { - values: Array; -}) { - const present = values.filter((value) => value !== undefined && value !== ""); - if (!present.length) { - return -; - } - return ( - - {present.map((value, index) => ( - - {String(value)} - - ))} - - ); -} - -function MutedText({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function desiredRecordRows( - recordSet: RecordSet, -): Array<{ label: string; value: React.ReactNode }> { - const rows: Array<{ label: string; value: React.ReactNode }> = [ - { label: "Owner name", value: recordSet.spec.name }, - { label: "Type", value: recordSet.spec.type }, - { label: "TTL", value: recordSet.spec.ttl ?? ttlMode(recordSet) }, - ]; - const alias = aliasOptions(recordSet); - if (alias) { - return [ - ...rows, - { label: "Alias target DNS name", value: String(alias.dnsName ?? "-") }, - { - label: "Alias target hosted zone ID", - value: String(alias.hostedZoneID ?? "-"), - }, - { - label: "Evaluate target health", - value: alias.evaluateTargetHealth ? "true" : "false", - }, - ]; - } - if (recordSet.spec.a?.addresses?.length) { - rows.push({ - label: "IPv4 addresses", - value: , - }); - } else if (recordSet.spec.aaaa?.addresses?.length) { - rows.push({ - label: "IPv6 addresses", - value: , - }); - } else if (recordSet.spec.txt?.values?.length) { - rows.push({ - label: "TXT values", - value: , - }); - } else if (recordSet.spec.cname?.target) { - rows.push({ label: "CNAME target", value: recordSet.spec.cname.target }); - } else if (recordSet.spec.mx?.records?.length) { - rows.push({ - label: "MX records", - value: ( - - {recordSet.spec.mx.records.map((record, index) => ( - - {record.preference ?? "-"} {record.exchange ?? "-"} - - ))} - - ), - }); - } else if (recordSet.spec.caa?.records?.length) { - rows.push({ - label: "CAA records", - value: ( - - {recordSet.spec.caa.records.map((record, index) => ( - - {record.flags ?? "-"} {record.tag ?? "-"} {record.value ?? "-"} - - ))} - - ), - }); - } else if (recordSet.spec.ns?.nameServers?.length) { - rows.push({ - label: "Name servers", - value: , - }); - } else { - rows.push({ label: "Value", value: recordSetValue(recordSet) }); - } - return rows; -} - -function providerOptionRows(recordSet: RecordSet): InfoRow[] { - const options = recordSet.spec.options ?? {}; - const rows: InfoRow[] = []; - const alias = aliasOptions(recordSet); - if (alias) { - return rows; - } - if ("ttl" in options) { - rows.push(["TTL mode", String(options.ttl ?? "-")]); - } - if ("proxied" in options) { - rows.push([ - "Proxy status", - Boolean(options.proxied) ? "Proxied" : "DNS only", - ]); - } - if ("comment" in options) { - rows.push(["Comment", String(options.comment ?? "-")]); - } - if (Array.isArray(options.tags)) { - rows.push(["Tags", ]); - } - return rows; -} - -function providerStatusRows(recordSet: RecordSet): InfoRow[] { - const data = recordSet.status?.provider?.data; - if (!data) { - return []; - } - const rows: InfoRow[] = []; - const cloudflareRecords = (data as { records?: unknown }).records; - if (Array.isArray(cloudflareRecords) && cloudflareRecords.length) { - rows.push([ - "Cloudflare records", - , - ]); - } - return rows; -} - -function aliasOptions(recordSet: RecordSet) { - const alias = recordSet.spec.options?.alias; - return isRecord(alias) - ? (alias as { - dnsName?: unknown; - hostedZoneID?: unknown; - evaluateTargetHealth?: unknown; - }) - : undefined; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function ttlMode(recordSet: RecordSet) { - if (aliasOptions(recordSet)) { - return "alias"; - } - if (recordSet.spec.options?.ttl === "Auto") { - return "Auto"; - } - return "-"; -} - -function zoneUnitForZone(zone: Zone, data: DnsData) { - return data.zoneUnits.items.find( - (item) => namespaceOf(item) === namespaceOf(zone) && nameOf(item) === nameOf(zone), - ); -} - -function zoneUnitOwnership( - recordSet: RecordSet, - zoneUnit?: ZoneUnit, -): ZoneUnitOwnership { - const refKey = recordSetClaimKey(namespaceOf(recordSet), nameOf(recordSet)); - const ownItem = zoneUnit?.spec.recordSets?.find( - (item) => recordSetClaimKey(item.recordSetNamespace, item.recordSetName) === refKey, - ); - if (ownItem) { - return { - ownerItem: ownItem, - ownsItem: true, - label: "Owned by this RecordSet", - ownerLabel: `${ownItem.recordSetNamespace}/${ownItem.recordSetName}`, - providerDeletion: - "Provider record set will be deleted by the controller when this RecordSet is deleted.", - }; - } - const conflict = zoneUnit?.spec.recordSets?.find( - (item) => - item.name === recordSet.spec.name && - (item.type === recordSet.spec.type || - item.type === "CNAME" || - recordSet.spec.type === "CNAME"), - ); - if (!conflict) { - return { - ownsItem: false, - label: "ZoneUnit pending", - ownerLabel: "-", - providerDeletion: - "No ZoneUnit owner item is visible for this record identity yet.", - }; - } - return { - ownerItem: conflict, - ownsItem: false, - label: "RecordSetConflict", - ownerLabel: `${conflict.recordSetNamespace}/${conflict.recordSetName}`, - providerDeletion: - "Provider record set will not be deleted by this RecordSet.", - }; -} - -function recordSetClaimKey(namespace: string, name: string) { - return `${namespace}\0${name}`; -} - -function providerLabel(recordSet: RecordSet) { - const value = - `${recordSet.apiVersion ?? ""} ${recordSet.kind ?? ""} ${providerRefText(recordSet.spec.provider)}`.toLowerCase(); - if (value.includes("cloudflare")) { - return "Cloudflare"; - } - if (value.includes("google")) { - return "Google Cloud DNS"; - } - return "AWS Route 53"; -} - -function findProviderIdentity( - identities: ProviderIdentity[], - zoneClass?: ZoneClass, -): ProviderIdentity | undefined { - const identityName = zoneClassIdentityName(zoneClass); - if (!zoneClass || !identityName) { - return undefined; - } - return identities.find( - (identity) => - namespaceOf(identity) === namespaceOf(zoneClass) && - nameOf(identity) === identityName, - ); -} - -function recordSetHealth(recordSet: RecordSet): Health { - const conditionHealth = resourceConditionsHealth( - recordSet.status?.conditions, - ["Accepted", "Programmed"], - ); - if (conditionHealth !== "Healthy") { - return conditionHealth; - } - const observed = recordSet.status?.observedGeneration; - const generation = recordSet.metadata?.generation; - if ( - observed !== undefined && - generation !== undefined && - observed < generation - ) { - return "Syncing"; - } - return "Healthy"; -} - -function resourceConditionsHealth( - conditions: DnsCondition[] | undefined, - requiredTypes: string[], -): Health { - if (!conditions?.length) { - return "Syncing"; - } - const required = requiredTypes.map((type) => - conditions.find((condition) => condition.type === type), - ); - if (required.some((condition) => condition?.status === "False")) { - return "Degraded"; - } - if (required.every((condition) => condition?.status === "True")) { - return "Healthy"; - } - return "Syncing"; -} - -function conditionSummary(conditions: DnsCondition[] | undefined) { - if (!conditions?.length) { - return "No condition"; - } - const condition = - conditions.find((item) => item.status === "False") ?? - conditions.find((item) => item.status === "Unknown") ?? - conditions[0]; - if (!condition) { - return "No condition"; - } - return `${condition.type}: ${condition.reason || condition.status}`; -} - -function HealthInline({ health }: { health: Health }) { - const color = - health === "Healthy" - ? ui.successText - : health === "Syncing" - ? ui.warningText - : ui.dangerText; - return ( - - - - {health} - - - ); -} - -function VitalsHealth({ health }: { health: Health }) { - const color = - health === "Healthy" - ? ui.successText - : health === "Syncing" - ? ui.warningText - : ui.dangerText; - const dotColor = - health === "Healthy" ? "success" : health === "Syncing" ? "warning" : "danger"; - return ( - - - - {health} - - - ); -} - -function SmallBadge({ - children, - selected, -}: { - children: React.ReactNode; - selected?: boolean; -}) { - return ( - - {children} - - ); -} export function RecordSetDeletePage({ zone, @@ -1456,9 +586,7 @@ export function RecordSetDeletePage({ const { showSuccess, showError, snackbar } = useNotice(); const fqdn = fqdnForRecordSet(recordSet, [zone]); const ownership = zoneUnitOwnership(recordSet, zoneUnitForZone(zone, data)); - const providerDeletion = ownership.ownsItem - ? "Provider record set will be deleted by the controller." - : "Provider record set will not be deleted by this RecordSet."; + const providerDeletion = ownership.providerDeletion; async function submit() { try { @@ -1513,8 +641,8 @@ export function RecordSetDeletePage({ mt: 1, }} > - The controller deletes the provider record only when this - RecordSet owns the ZoneUnit item. + Provider cleanup requires ownership bound to this RecordSet UID + and current controller authority. @@ -1539,7 +667,7 @@ export function RecordSetDeletePage({ - RecordSetConflict + {ownership.label} - This RecordSet is not the ZoneUnit owner, so deleting it will - not delete the provider-side record set. + An absent or different RecordSet UID cannot authorize deletion + of the provider-side record. ) : null} diff --git a/pkg/ts/ui/src/views/zones/recordSetPresentation.tsx b/pkg/ts/ui/src/views/zones/recordSetPresentation.tsx new file mode 100644 index 0000000..e160785 --- /dev/null +++ b/pkg/ts/ui/src/views/zones/recordSetPresentation.tsx @@ -0,0 +1,269 @@ +/** @jsxRuntime classic */ +import React from "react"; +import { Box, Stack, Typography } from "../../components/primitives"; +import { nameOf, namespaceOf, zoneClassIdentityName } from "../../resources"; +import type { DnsCondition, ProviderIdentity, RecordSet, Zone, ZoneClass, ZoneUnit, ZoneUnitRecordSetSpec } from "../../types/resources"; +import { type DnsData, ui } from "../common/ui"; + +export type Health = "Healthy" | "Syncing" | "Degraded"; + +export type ZoneUnitOwnership = { + ownerItem?: ZoneUnitRecordSetSpec; + ownsItem: boolean; + label: string; + ownerLabel: string; + providerDeletion: string; +}; + +export function providerRefText(provider?: RecordSet["spec"]["provider"]) { + return provider ? `${provider.name}/${provider.version}` : ""; +} + +export function aliasOptions(recordSet: RecordSet) { + const alias = recordSet.spec.options?.alias; + return isRecord(alias) + ? (alias as { + dnsName?: unknown; + hostedZoneID?: unknown; + evaluateTargetHealth?: unknown; + }) + : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function ttlMode(recordSet: RecordSet) { + if (aliasOptions(recordSet)) { + return "alias"; + } + if (recordSet.spec.options?.ttl === "Auto") { + return "Auto"; + } + return "-"; +} + +export function zoneUnitForZone(zone: Zone, data: DnsData) { + return data.zoneUnits.items.find( + (item) => namespaceOf(item) === namespaceOf(zone) && nameOf(item) === nameOf(zone), + ); +} + +export function zoneUnitOwnership( + recordSet: RecordSet, + zoneUnit?: ZoneUnit, +): ZoneUnitOwnership { + const refKey = recordSetClaimKey(namespaceOf(recordSet), nameOf(recordSet)); + const ownItem = zoneUnit?.spec.recordSets?.find( + (item) => recordSetClaimKey(item.recordSetNamespace, item.recordSetName) === refKey, + ); + if (ownItem) { + const uid = recordSet.metadata?.uid; + if (!uid || ownItem.recordSetUID !== uid) { + return { + ownerItem: ownItem, + ownsItem: false, + label: !uid || !ownItem.recordSetUID + ? "Incarnation unverified" + : "Different RecordSet incarnation", + ownerLabel: `${ownItem.recordSetNamespace}/${ownItem.recordSetName}`, + providerDeletion: + "Provider cleanup is blocked until ownership is bound to this RecordSet UID. A missing or older receipt cannot authorize it.", + }; + } + return { + ownerItem: ownItem, + ownsItem: true, + label: "Owned by this RecordSet", + ownerLabel: `${ownItem.recordSetNamespace}/${ownItem.recordSetName}`, + providerDeletion: + "The controller can clean up only with provider ownership bound to this RecordSet UID; otherwise deletion waits.", + }; + } + const conflict = zoneUnit?.spec.recordSets?.find( + (item) => + item.name === recordSet.spec.name && + (item.type === recordSet.spec.type || + item.type === "CNAME" || + recordSet.spec.type === "CNAME"), + ); + if (!conflict) { + return { + ownsItem: false, + label: "ZoneUnit pending", + ownerLabel: "-", + providerDeletion: + "No ZoneUnit owner item is visible for this record identity yet.", + }; + } + return { + ownerItem: conflict, + ownsItem: false, + label: "RecordSetConflict", + ownerLabel: `${conflict.recordSetNamespace}/${conflict.recordSetName}`, + providerDeletion: + "Provider record set will not be deleted by this RecordSet.", + }; +} + +function recordSetClaimKey(namespace: string, name: string) { + return `${namespace}\0${name}`; +} + +export function providerLabel(recordSet: RecordSet) { + const value = + `${recordSet.apiVersion ?? ""} ${recordSet.kind ?? ""} ${providerRefText(recordSet.spec.provider)}`.toLowerCase(); + if (value.includes("cloudflare")) { + return "Cloudflare"; + } + if (value.includes("google")) { + return "Google Cloud DNS"; + } + return "AWS Route 53"; +} + +export function findProviderIdentity( + identities: ProviderIdentity[], + zoneClass?: ZoneClass, +): ProviderIdentity | undefined { + const identityName = zoneClassIdentityName(zoneClass); + if (!zoneClass || !identityName) { + return undefined; + } + return identities.find( + (identity) => + namespaceOf(identity) === namespaceOf(zoneClass) && + nameOf(identity) === identityName, + ); +} + +export function recordSetHealth(recordSet: RecordSet): Health { + const conditionHealth = resourceConditionsHealth( + recordSet.status?.conditions, + ["Accepted", "Programmed"], + ); + if (conditionHealth !== "Healthy") { + return conditionHealth; + } + const observed = recordSet.status?.observedGeneration; + const generation = recordSet.metadata?.generation; + if ( + observed !== undefined && + generation !== undefined && + observed < generation + ) { + return "Syncing"; + } + return "Healthy"; +} + +export function resourceConditionsHealth( + conditions: DnsCondition[] | undefined, + requiredTypes: string[], +): Health { + if (!conditions?.length) { + return "Syncing"; + } + const required = requiredTypes.map((type) => + conditions.find((condition) => condition.type === type), + ); + if (required.some((condition) => condition?.status === "False")) { + return "Degraded"; + } + if (required.every((condition) => condition?.status === "True")) { + return "Healthy"; + } + return "Syncing"; +} + +export function conditionSummary(conditions: DnsCondition[] | undefined) { + if (!conditions?.length) { + return "No condition"; + } + const condition = + conditions.find((item) => item.status === "False") ?? + conditions.find((item) => item.status === "Unknown") ?? + conditions[0]; + if (!condition) { + return "No condition"; + } + return `${condition.type}: ${condition.reason || condition.status}`; +} + +export function HealthInline({ health }: { health: Health }) { + const color = + health === "Healthy" + ? ui.successText + : health === "Syncing" + ? ui.warningText + : ui.dangerText; + return ( + + + + {health} + + + ); +} + +export function VitalsHealth({ health }: { health: Health }) { + const color = + health === "Healthy" + ? ui.successText + : health === "Syncing" + ? ui.warningText + : ui.dangerText; + const dotColor = + health === "Healthy" ? "success" : health === "Syncing" ? "warning" : "danger"; + return ( + + + + {health} + + + ); +} + +export function SmallBadge({ + children, + selected, +}: { + children: React.ReactNode; + selected?: boolean; +}) { + return ( + + {children} + + ); +}