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}
+
+ );
+}