diff --git a/Taskfile.test-infra.yml b/Taskfile.test-infra.yml index 5c4a4c16..a006548d 100644 --- a/Taskfile.test-infra.yml +++ b/Taskfile.test-infra.yml @@ -99,6 +99,7 @@ tasks: - task: cert-manager-upstream - task: cert-manager-downstream - task: nso-image + - task: dns-crds - task: prepare-upstream - task: eg-downstream - task: extension-server @@ -157,6 +158,19 @@ tasks: - '{{.KIND}} load docker-image {{.IMG}} --name {{.UPSTREAM_CLUSTER}}' - '{{.KIND}} load docker-image {{.IMG}} --name {{.DOWNSTREAM_CLUSTER}}' + dns-crds: + desc: "Install DNSZone / DNSRecordSet / DNSZoneClass CRDs from the dns-operator Go module on the upstream cluster. Required before prepare-upstream when gateway.enableDNSIntegration is true (the operator indexes and watches these types at startup)." + vars: + # Escape {{.Dir}} so Task does not treat it as a Task template var; + # go list must receive the literal {{.Dir}} format string. + DNS_OPERATOR_CRD_DIR: + sh: go list -m -f '{{`{{.Dir}}`}}' go.miloapis.com/dns-operator + cmds: + - echo "📜 installing dns-operator CRDs (upstream) from {{.DNS_OPERATOR_CRD_DIR}}" + - kubectl --context {{.UPSTREAM_CTX}} apply -f {{.DNS_OPERATOR_CRD_DIR}}/config/crd/bases/dns.networking.miloapis.com_dnszoneclasses.yaml + - kubectl --context {{.UPSTREAM_CTX}} apply -f {{.DNS_OPERATOR_CRD_DIR}}/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml + - kubectl --context {{.UPSTREAM_CTX}} apply -f {{.DNS_OPERATOR_CRD_DIR}}/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml + prepare-upstream: desc: "Deploy the NSO manager + webhook (config/e2e) on the upstream cluster, with the prod-base memory profile." cmds: diff --git a/config/e2e/config.yaml b/config/e2e/config.yaml index 73fd2830..812afa1a 100644 --- a/config/e2e/config.yaml +++ b/config/e2e/config.yaml @@ -8,6 +8,10 @@ webhookServer: gateway: targetDomain: prism.e2e.env.datum.net downstreamGatewayClassName: datum-downstream-gateway-e2e + # Enable DNSRecordSet creation for claimed Gateway hostnames. Requires + # dns-operator CRDs installed via `task test-infra:dns-crds` (wired into + # test-infra:up) before the operator starts. + enableDNSIntegration: true permittedTLSOptions: gateway.networking.datumapis.com/certificate-issuer: [] downstreamResourceManagement: diff --git a/internal/controller/gateway_dns_controller.go b/internal/controller/gateway_dns_controller.go index 0d6ca179..8bb08fc3 100644 --- a/internal/controller/gateway_dns_controller.go +++ b/internal/controller/gateway_dns_controller.go @@ -547,28 +547,46 @@ func buildDesiredDNSRecordSet( } } +// relativeOwnerName returns the DNS owner name for hostname relative to +// zoneDomain. Trailing dots are stripped before comparison. Apex (hostname +// equals the zone domain) yields "@". Multi-label prefixes are preserved with +// their original casing (e.g. "a.b.example.com" in "example.com" → "a.b"). +// If hostname is not under the zone, the absolute hostname without a trailing +// dot is returned as a safe fallback. +func relativeOwnerName(hostname, zoneDomain string) string { + h := strings.TrimSuffix(hostname, ".") + z := strings.TrimSuffix(zoneDomain, ".") + if h == "" || z == "" { + return h + } + if strings.EqualFold(h, z) { + return "@" + } + // Case-insensitive suffix match on "."+zoneDomain, but keep original labels. + if len(h) > len(z)+1 && h[len(h)-len(z)-1] == '.' && strings.EqualFold(h[len(h)-len(z):], z) { + return h[:len(h)-len(z)-1] + } + return h +} + // buildDesiredDNSRecordSetSpec constructs the DNSRecordSetSpec that points -// hostname at canonicalHostname. Both values are normalized to absolute FQDNs -// (trailing dot) before being written into the record entry. The record type -// is CNAME for non-apex hostnames and ALIAS for apex domains, as determined -// by the caller. +// hostname at canonicalHostname. The owner name is written relative to +// dnsZone.Spec.DomainName (e.g. "api", "@"); CNAME/ALIAS content is +// normalized to an absolute FQDN with a trailing dot. The record type is +// CNAME for non-apex hostnames and ALIAS for apex domains, as determined by +// the caller. func buildDesiredDNSRecordSetSpec( hostname, canonicalHostname string, dnsZone dnsv1alpha1.DNSZone, rrType dnsv1alpha1.RRType, ) dnsv1alpha1.DNSRecordSetSpec { - fqdnHostname := hostname - if !strings.HasSuffix(fqdnHostname, ".") { - fqdnHostname = fqdnHostname + "." - } - fqdnTarget := canonicalHostname if !strings.HasSuffix(fqdnTarget, ".") { fqdnTarget = fqdnTarget + "." } var entry dnsv1alpha1.RecordEntry - entry.Name = fqdnHostname + entry.Name = relativeOwnerName(hostname, dnsZone.Spec.DomainName) entry.TTL = ptr.To(int64(300)) switch rrType { diff --git a/internal/controller/gateway_dns_controller_test.go b/internal/controller/gateway_dns_controller_test.go index 3f2d13ff..5f994512 100644 --- a/internal/controller/gateway_dns_controller_test.go +++ b/internal/controller/gateway_dns_controller_test.go @@ -422,7 +422,7 @@ func TestEnsureDNSRecordSets(t *testing.T) { assert.Equal(t, dnsv1alpha1.RRTypeCNAME, rs.Spec.RecordType) assert.Equal(t, "example-com", rs.Spec.DNSZoneRef.Name) require.Len(t, rs.Spec.Records, 1) - assert.Equal(t, "api.example.com.", rs.Spec.Records[0].Name) + assert.Equal(t, "api", rs.Spec.Records[0].Name) require.NotNil(t, rs.Spec.Records[0].CNAME) // canonical hostname target should end with a dot assert.True(t, len(rs.Spec.Records[0].CNAME.Content) > 0) @@ -450,6 +450,7 @@ func TestEnsureDNSRecordSets(t *testing.T) { rs := list.Items[0] assert.Equal(t, dnsv1alpha1.RRTypeALIAS, rs.Spec.RecordType) require.Len(t, rs.Spec.Records, 1) + assert.Equal(t, "@", rs.Spec.Records[0].Name) require.NotNil(t, rs.Spec.Records[0].ALIAS) }, }, @@ -480,7 +481,7 @@ func TestEnsureDNSRecordSets(t *testing.T) { // Should use the more specific subdomain zone. assert.Equal(t, "api-example-com", rs.Spec.DNSZoneRef.Name) require.Len(t, rs.Spec.Records, 1) - assert.Equal(t, "v1.api.example.com.", rs.Spec.Records[0].Name) + assert.Equal(t, "v1", rs.Spec.Records[0].Name) }, }, { @@ -756,12 +757,16 @@ func TestEnsureDNSRecordSets_UpdateExistingRecord(t *testing.T) { "expected RecordCreated or RecordUpdated on existing platform-managed record", ) - // The DNSRecordSet should still exist and contain the new canonical hostname. + // The DNSRecordSet should still exist, converge owner name to relative form, + // and contain the new canonical hostname. var updatedRS dnsv1alpha1.DNSRecordSet require.NoError(t, cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: existingRSName}, &updatedRS)) require.Len(t, updatedRS.Spec.Records, 1) + assert.Equal(t, "api", updatedRS.Spec.Records[0].Name) + require.NotNil(t, updatedRS.Spec.Records[0].CNAME) // The canonical hostname target should end with a dot. assert.True(t, len(updatedRS.Spec.Records[0].CNAME.Content) > 0) + assert.Equal(t, ".", string(updatedRS.Spec.Records[0].CNAME.Content[len(updatedRS.Spec.Records[0].CNAME.Content)-1])) } // --------------------------------------------------------------------------- @@ -1070,45 +1075,120 @@ func TestBuildDesiredDNSRecordSetSpec(t *testing.T) { t.Parallel() zone := *newDNSZone("ns", "example-com", "example.com") + apiZone := *newDNSZone("ns", "api-example-com", "api.example.com") tests := []struct { name string hostname string canonicalHostname string + zone dnsv1alpha1.DNSZone rrType dnsv1alpha1.RRType wantRecordType dnsv1alpha1.RRType - wantFQDNName string + wantName string wantFQDNContent string wantNilCNAME bool wantNilALIAS bool }{ { - name: "CNAME record gets trailing dots on both name and content", + name: "CNAME uses relative owner name; content stays FQDN", hostname: "api.example.com", canonicalHostname: "gw.gateways.test.local", + zone: zone, rrType: dnsv1alpha1.RRTypeCNAME, wantRecordType: dnsv1alpha1.RRTypeCNAME, - wantFQDNName: "api.example.com.", + wantName: "api", wantFQDNContent: "gw.gateways.test.local.", wantNilALIAS: true, }, { - name: "ALIAS record gets trailing dots on both name and content", + name: "ALIAS apex uses @; content stays FQDN", hostname: "example.com", canonicalHostname: "gw.gateways.test.local", + zone: zone, rrType: dnsv1alpha1.RRTypeALIAS, wantRecordType: dnsv1alpha1.RRTypeALIAS, - wantFQDNName: "example.com.", + wantName: "@", wantFQDNContent: "gw.gateways.test.local.", wantNilCNAME: true, }, { - name: "already FQDN inputs do not get double dots", + name: "case-insensitive apex still yields @", + hostname: "Example.COM", + canonicalHostname: "gw.gateways.test.local", + zone: zone, + rrType: dnsv1alpha1.RRTypeALIAS, + wantRecordType: dnsv1alpha1.RRTypeALIAS, + wantName: "@", + wantFQDNContent: "gw.gateways.test.local.", + wantNilCNAME: true, + }, + { + name: "zone DomainName with trailing dot still yields relative name", + hostname: "api.example.com", + canonicalHostname: "gw.gateways.test.local", + zone: func() dnsv1alpha1.DNSZone { + z := zone + z.Spec.DomainName = "example.com." + return z + }(), + rrType: dnsv1alpha1.RRTypeCNAME, + wantRecordType: dnsv1alpha1.RRTypeCNAME, + wantName: "api", + wantFQDNContent: "gw.gateways.test.local.", + wantNilALIAS: true, + }, + { + name: "hostname with trailing dot still yields relative name", hostname: "api.example.com.", canonicalHostname: "gw.gateways.test.local.", + zone: zone, + rrType: dnsv1alpha1.RRTypeCNAME, + wantRecordType: dnsv1alpha1.RRTypeCNAME, + wantName: "api", + wantFQDNContent: "gw.gateways.test.local.", + wantNilALIAS: true, + }, + { + name: "multi-label relative owner name", + hostname: "a.b.example.com", + canonicalHostname: "gw.gateways.test.local", + zone: zone, + rrType: dnsv1alpha1.RRTypeCNAME, + wantRecordType: dnsv1alpha1.RRTypeCNAME, + wantName: "a.b", + wantFQDNContent: "gw.gateways.test.local.", + wantNilALIAS: true, + }, + { + name: "preserves original casing of relative labels", + hostname: "Help.API.example.com", + canonicalHostname: "gw.gateways.test.local", + zone: zone, + rrType: dnsv1alpha1.RRTypeCNAME, + wantRecordType: dnsv1alpha1.RRTypeCNAME, + wantName: "Help.API", + wantFQDNContent: "gw.gateways.test.local.", + wantNilALIAS: true, + }, + { + name: "relative name against more specific subdomain zone", + hostname: "v1.api.example.com", + canonicalHostname: "gw.gateways.test.local", + zone: apiZone, + rrType: dnsv1alpha1.RRTypeCNAME, + wantRecordType: dnsv1alpha1.RRTypeCNAME, + wantName: "v1", + wantFQDNContent: "gw.gateways.test.local.", + wantNilALIAS: true, + }, + { + name: "hostname outside zone falls back to absolute without trailing dot", + hostname: "other.example.org", + canonicalHostname: "gw.gateways.test.local", + zone: zone, rrType: dnsv1alpha1.RRTypeCNAME, wantRecordType: dnsv1alpha1.RRTypeCNAME, - wantFQDNName: "api.example.com.", + wantName: "other.example.org", wantFQDNContent: "gw.gateways.test.local.", wantNilALIAS: true, }, @@ -1117,14 +1197,14 @@ func TestBuildDesiredDNSRecordSetSpec(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - spec := buildDesiredDNSRecordSetSpec(tt.hostname, tt.canonicalHostname, zone, tt.rrType) + spec := buildDesiredDNSRecordSetSpec(tt.hostname, tt.canonicalHostname, tt.zone, tt.rrType) assert.Equal(t, tt.wantRecordType, spec.RecordType) - assert.Equal(t, "example-com", spec.DNSZoneRef.Name) + assert.Equal(t, tt.zone.Name, spec.DNSZoneRef.Name) require.Len(t, spec.Records, 1) entry := spec.Records[0] - assert.Equal(t, tt.wantFQDNName, entry.Name) + assert.Equal(t, tt.wantName, entry.Name) require.NotNil(t, entry.TTL) assert.Equal(t, int64(300), *entry.TTL) diff --git a/test/e2e/gateway/chainsaw-test.yaml b/test/e2e/gateway/chainsaw-test.yaml index 1253d66d..20f0b11d 100644 --- a/test/e2e/gateway/chainsaw-test.yaml +++ b/test/e2e/gateway/chainsaw-test.yaml @@ -510,6 +510,107 @@ spec: kubectl get dnsendpoints -n $DOWNSTREAM_NAMESPACE -o yaml kubectl get svc -n $DOWNSTREAM_NAMESPACE -o yaml + # After the Gateway is Accepted and has claimed test.e2e.env.datum.net, + # provision a ready DNSZone + matching Domain nameservers so the Gateway + # DNS controller creates a DNSRecordSet. Assert the owner name is relative + # to the zone (test), while CNAME content remains an absolute FQDN. + - name: Assert Gateway DNSRecordSet uses relative owner name + try: + - create: + cluster: nso-standard + resource: + apiVersion: dns.networking.miloapis.com/v1alpha1 + kind: DNSZone + metadata: + name: e2e-env-datum-net + spec: + domainName: e2e.env.datum.net + dnsZoneClassName: default + + - description: | + Simulate a programmed Datum DNS zone. dns-operator is not running + in this e2e, so Accepted/Programmed and nameservers are patched. + script: + cluster: nso-standard + content: | + kubectl -n $NAMESPACE patch dnszone e2e-env-datum-net \ + --subresource=status --type=merge \ + -p '{"status":{"nameservers":["ns1.e2e.example.net","ns2.e2e.example.net"],"conditions":[{"type":"Accepted","status":"True","reason":"Accepted","message":"test","lastTransitionTime":"2025-02-24T23:59:09Z"},{"type":"Programmed","status":"True","reason":"Programmed","message":"test","lastTransitionTime":"2025-02-24T23:59:09Z"}]}}' + + - description: | + Match Domain status.nameservers to the DNSZone so HasDNSAuthority + passes. Merge-patch only nameservers + nextRefreshAttempt (do not + touch conditions). Re-patch until the synthetic NS stick (guards + against in-flight RDAP refresh). + script: + cluster: nso-standard + timeout: 60s + content: | + patch_domain_ns() { + kubectl -n $NAMESPACE patch domain test-domain \ + --subresource=status --type=merge \ + -p '{"status":{"nameservers":[{"hostname":"ns1.e2e.example.net"},{"hostname":"ns2.e2e.example.net"}],"registration":{"nextRefreshAttempt":"2099-01-01T00:00:00Z"}}}' + } + patch_domain_ns + for i in $(seq 1 30); do + ns=$(kubectl -n $NAMESPACE get domain test-domain -o jsonpath='{.status.nameservers[*].hostname}') + case " $ns " in + *"ns1.e2e.example.net"*) + echo "Domain nameservers ready: $ns" + exit 0 + ;; + esac + echo "Domain nameservers not ready yet (got: '$ns'); re-patching ($i/30)" + patch_domain_ns || true + sleep 2 + done + echo "timed out waiting for synthetic Domain nameservers" >&2 + kubectl -n $NAMESPACE get domain test-domain -o yaml >&2 + exit 1 + + - description: | + Wait for the Gateway-managed DNSRecordSet and assert relative owner + name (test) plus absolute FQDN CNAME content. Deterministic name: + test-gateway-{sha256("test.e2e.env.datum.net")[:8]}. + script: + cluster: nso-standard + timeout: 120s + content: | + RS=test-gateway-f9ab7844 + for i in $(seq 1 60); do + if kubectl -n $NAMESPACE get dnsrecordset "$RS" >/dev/null 2>&1; then + name=$(kubectl -n $NAMESPACE get dnsrecordset "$RS" -o jsonpath='{.spec.records[0].name}') + content=$(kubectl -n $NAMESPACE get dnsrecordset "$RS" -o jsonpath='{.spec.records[0].cname.content}') + zone=$(kubectl -n $NAMESPACE get dnsrecordset "$RS" -o jsonpath='{.spec.dnsZoneRef.name}') + rtype=$(kubectl -n $NAMESPACE get dnsrecordset "$RS" -o jsonpath='{.spec.recordType}') + if [ "$name" = "test" ] && [ "$zone" = "e2e-env-datum-net" ] && [ "$rtype" = "CNAME" ] \ + && [ -n "$content" ] && [ "$content" != "." ]; then + case "$content" in + *.) + echo "DNSRecordSet ok: name=$name type=$rtype content=$content" + exit 0 + ;; + esac + fi + echo "DNSRecordSet present but not ready (name='$name' type='$rtype' zone='$zone' content='$content') ($i/60)" + else + echo "waiting for dnsrecordset/$RS ($i/60)" + fi + sleep 2 + done + echo "timed out waiting for relative-owner DNSRecordSet" >&2 + kubectl -n $NAMESPACE get dnsrecordsets -o yaml >&2 + exit 1 + catch: + - script: + cluster: nso-standard + content: | + kubectl -n network-services-operator-system logs -l app.kubernetes.io/name=network-services-operator + kubectl get domain test-domain -n $NAMESPACE -o yaml + kubectl get dnszone e2e-env-datum-net -n $NAMESPACE -o yaml + kubectl get dnsrecordsets -n $NAMESPACE -o yaml + kubectl get gateway test-gateway -n $NAMESPACE -o yaml + - name: Provision HTTPRoute try: # Get the target namespace UID