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
14 changes: 14 additions & 0 deletions Taskfile.test-infra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions config/e2e/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 28 additions & 10 deletions internal/controller/gateway_dns_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
106 changes: 93 additions & 13 deletions internal/controller/gateway_dns_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
},
},
Expand Down Expand Up @@ -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)
},
},
{
Expand Down Expand Up @@ -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]))
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
},
Expand All @@ -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)

Expand Down
101 changes: 101 additions & 0 deletions test/e2e/gateway/chainsaw-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading