Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/controller/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const (
conditionTypeProgrammed = "Programmed"
)

// dnsZoneReasonPendingDomainVerification is the Accepted=False reason the DNS
// operator sets on a DNSZone it holds back until its Domain is verified.
const dnsZoneReasonPendingDomainVerification = "PendingDomainVerification"

// cert-manager condition status values used when parsing unstructured Certificate objects.
const (
certManagerConditionStatusTrue = "True"
Expand Down
26 changes: 24 additions & 2 deletions internal/controller/domain_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,12 +389,17 @@ func (r *DomainReconciler) attemptDNSZoneVerification(
// Evaluate zones; any one matching is sufficient
sawNotReady := false
sawReady := false
sawPendingSubdomain := false
for _, z := range zones {
zoneName := z.GetName()

// Must be Accepted=True and Programmed=True
// Must be Accepted=True and Programmed=True, or held back by the DNS
// operator until this Domain is verified. The DNS operator won't serve
// a zone for an unverified domain, so a zone waiting on verification
// never becomes Programmed; requiring that would deadlock.
accepted := false
programmed := false
pendingVerification := false
if conds, found, _ := unstructured.NestedSlice(z.Object, jsonKeyStatus, "conditions"); found {
for _, c := range conds {
cm, ok := c.(map[string]any)
Expand All @@ -406,12 +411,24 @@ func (r *DomainReconciler) attemptDNSZoneVerification(
if ct == conditionTypeAccepted && cs == certManagerConditionStatusTrue {
accepted = true
}
if ct == conditionTypeAccepted && cs != certManagerConditionStatusTrue {
reason, _ := cm["reason"].(string)
pendingVerification = reason == dnsZoneReasonPendingDomainVerification
}
if ct == conditionTypeProgrammed && cs == certManagerConditionStatusTrue {
programmed = true
}
}
}
if !accepted || !programmed {
// A waiting zone only counts at the apex. Every zone shares the same
// nameservers, and a subdomain with no delegation of its own reports its
// parent's nameservers. Without this check, anyone could verify
// sub.example.com once example.com was delegated to Datum.
if pendingVerification && !domainStatus.Apex {
sawPendingSubdomain = true
continue
}
if !pendingVerification && (!accepted || !programmed) {
sawNotReady = true
// Keep evaluating other zones in case one is ready.
continue
Expand Down Expand Up @@ -448,6 +465,11 @@ func (r *DomainReconciler) attemptDNSZoneVerification(
verifiedDNSZoneCondition.Reason = networkingv1alpha.DomainReasonDNSZoneNotReady
verifiedDNSZoneCondition.Message = "DNSZone exists but is not yet Accepted and Programmed"
}
if sawPendingSubdomain && !sawReady {
verifiedDNSZoneCondition.Reason = networkingv1alpha.DomainReasonDNSZoneNotReady
verifiedDNSZoneCondition.Message = "Nameserver delegation verifies only a registered domain; " +
"verify this subdomain with the TXT record or HTTP token, or verify its parent domain"
}
}

func (r *DomainReconciler) attemptDNSVerification(
Expand Down
78 changes: 78 additions & 0 deletions internal/controller/domain_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,61 @@ func TestDomainVerification(t *testing.T) {
assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone), "expected VerifiedDNSZone=True to be present")
},
},
{
name: "dnszone waiting on verification verifies apex domain",
reconcileCount: 2,
domain: newDomain(upstreamNamespace.Name, "dnszone-pending", func(domain *networkingv1alpha.Domain) {
domain.Status.Verification = &networkingv1alpha.DomainVerificationStatus{
NextVerificationAttempt: metav1.Time{Time: time.Unix(0, 0)},
}
}),
objects: []client.Object{pendingDNSZone(upstreamNamespace.Name, "zone-pending", "dnszone-pending")},
registryLookupDomain: func(ctx context.Context, domain string, opts registrydata.LookupOptions) (*registrydata.DomainResult, error) {
return &registrydata.DomainResult{
Registration: &networkingv1alpha.Registration{},
Nameservers: []networkingv1alpha.Nameserver{{Hostname: "ns1.provider.net."}},
}, nil
},
assert: func(t *testing.T, domain *networkingv1alpha.Domain, _ ctrl.Result) {
assert.True(t, domain.Status.Apex)
assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerified))
assert.True(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone))
},
},
{
name: "dnszone waiting on verification does not verify subdomain",
reconcileCount: 2,
domain: newDomain(upstreamNamespace.Name, "dnszone-pending-sub", func(domain *networkingv1alpha.Domain) {
domain.Spec.DomainName = "app.example.com"
domain.Status.Verification = &networkingv1alpha.DomainVerificationStatus{
NextVerificationAttempt: metav1.Time{Time: time.Unix(0, 0)},
}
}),
lookupTXT: func(ctx context.Context, name string) ([]string, error) {
return nil, &net.DNSError{IsNotFound: true}
},
httpGet: func(ctx context.Context, url string) ([]byte, *http.Response, error) {
return nil, &http.Response{StatusCode: http.StatusNotFound}, nil
},
objects: []client.Object{pendingDNSZone(upstreamNamespace.Name, "zone-pending-sub", "dnszone-pending-sub")},
// The subdomain has no delegation of its own, so the lookup reports
// the parent's nameservers, which match the shared zone nameservers.
registryLookupDomain: func(ctx context.Context, domain string, opts registrydata.LookupOptions) (*registrydata.DomainResult, error) {
return &registrydata.DomainResult{
Registration: &networkingv1alpha.Registration{},
Nameservers: []networkingv1alpha.Nameserver{{Hostname: "ns1.provider.net."}},
}, nil
},
assert: func(t *testing.T, domain *networkingv1alpha.Domain, _ ctrl.Result) {
assert.False(t, domain.Status.Apex)
assert.False(t, apimeta.IsStatusConditionTrue(domain.Status.Conditions, networkingv1alpha.DomainConditionVerified))
cond := apimeta.FindStatusCondition(domain.Status.Conditions, networkingv1alpha.DomainConditionVerifiedDNSZone)
if assert.NotNil(t, cond) {
assert.Equal(t, metav1.ConditionFalse, cond.Status)
assert.Equal(t, networkingv1alpha.DomainReasonDNSZoneNotReady, cond.Reason)
}
},
},
{
name: "http token not found",
lookupTXT: func(ctx context.Context, name string) ([]string, error) {
Expand Down Expand Up @@ -584,6 +639,29 @@ func TestValidDomainGate_InvalidApex_SetsConditionAndSkipsFlows(t *testing.T) {
}
}

// pendingDNSZone returns a DNSZone the DNS operator is holding back until the
// named Domain is verified: Accepted=False with the waiting reason, never
// Programmed, and nameservers published from its class.
func pendingDNSZone(namespace, name, domainName string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dns.networking.miloapis.com/v1alpha1",
"kind": "DNSZone",
"metadata": map[string]any{
"name": name,
"namespace": namespace,
},
"status": map[string]any{
"nameservers": []any{"ns1.provider.net.", "ns2.provider.net."},
"conditions": []any{
map[string]any{"type": "Accepted", "status": "False", "reason": dnsZoneReasonPendingDomainVerification},
},
"domainRef": map[string]any{"name": domainName},
},
},
}
}

func newDomain(namespace, name string, opts ...func(*networkingv1alpha.Domain)) *networkingv1alpha.Domain {
domain := &networkingv1alpha.Domain{
ObjectMeta: metav1.ObjectMeta{
Expand Down
Loading