From b15a453024baa18133a0a661001083d292b84767 Mon Sep 17 00:00:00 2001 From: Patryk Matuszak Date: Mon, 4 May 2026 16:06:43 +0200 Subject: [PATCH 1/4] Render DNS forwarding to other clusters --- .../openshift-dns/dns/configmap.yaml | 1 + pkg/components/controllers.go | 8 +- pkg/config/c2cc.go | 39 ++++++ pkg/config/c2cc_test.go | 112 ++++++++++++++++++ pkg/controllers/c2cc/helpers_test.go | 11 ++ 5 files changed, 169 insertions(+), 2 deletions(-) diff --git a/assets/components/openshift-dns/dns/configmap.yaml b/assets/components/openshift-dns/dns/configmap.yaml index 2b4194e991..8e7cdc3ae0 100644 --- a/assets/components/openshift-dns/dns/configmap.yaml +++ b/assets/components/openshift-dns/dns/configmap.yaml @@ -1,6 +1,7 @@ apiVersion: v1 data: Corefile: | + {{- .C2CCDNSBlocks }} .:5353 { bufsize 1232 errors diff --git a/pkg/components/controllers.go b/pkg/components/controllers.go index ded367dfca..8e14684c1e 100644 --- a/pkg/components/controllers.go +++ b/pkg/components/controllers.go @@ -304,8 +304,12 @@ func startDNSController(ctx context.Context, cfg *config.Config, kubeconfigPath hostsEnabled := cfg.DNS.Hosts.Status == config.HostsStatusEnabled extraParams := assets.RenderParams{ - "ClusterIP": cfg.Network.DNS, - "HostsEnabled": hostsEnabled, + "ClusterIP": cfg.Network.DNS, + "HostsEnabled": hostsEnabled, + "C2CCDNSBlocks": "", + } + if cfg.C2CC.IsEnabled() { + extraParams["C2CCDNSBlocks"] = config.RenderC2CCDNSBlocks(cfg.C2CC.Resolved) } if err := assets.ApplyServices(ctx, svc, renderTemplate, renderParamsFromConfig(cfg, extraParams), kubeconfigPath); err != nil { diff --git a/pkg/config/c2cc.go b/pkg/config/c2cc.go index ff77458eb0..4d1f43f486 100644 --- a/pkg/config/c2cc.go +++ b/pkg/config/c2cc.go @@ -39,6 +39,7 @@ type ResolvedRemoteCluster struct { ClusterNetwork []*net.IPNet ServiceNetwork []*net.IPNet Domain string + DNSIP string // 10th IP of ServiceNetwork[0], computed during validation when Domain is set } func (rc *ResolvedRemoteCluster) AllCIDRs() []*net.IPNet { @@ -264,6 +265,14 @@ func validateRemoteCluster( } else { seenRemoteDomains[rc.Domain] = i } + if len(rc.ServiceNetwork) > 0 { + dnsIP, err := getClusterDNS(rc.ServiceNetwork[0]) + if err != nil { + errs = append(errs, fmt.Errorf("%s: failed to compute DNS IP from serviceNetwork[0] %q: %w", label, rc.ServiceNetwork[0], err)) + } else { + res.DNSIP = dnsIP + } + } } errs = append(errs, validateIPFamilyConsistencyNets(res.ClusterNetwork, label+".clusterNetwork")...) @@ -345,3 +354,33 @@ func checkCIDRConflicts(cidr *net.IPNet, cidrStr, label string, seenCIDRs []labe func cidrsOverlap(a, b *net.IPNet) bool { return a.Contains(b.IP) || b.Contains(a.IP) } + +// RenderC2CCDNSBlocks generates CoreDNS server blocks for cross-cluster DNS. +func RenderC2CCDNSBlocks(resolved []ResolvedRemoteCluster) string { + var blocks []string + for _, rc := range resolved { + if rc.Domain == "" { + continue + } + blocks = append(blocks, formatDNSBlock(rc.Domain, rc.DNSIP)) + } + if len(blocks) == 0 { + return "" + } + return "\n" + strings.Join(blocks, "\n") +} + +func formatDNSBlock(domain, dnsIP string) string { + return fmt.Sprintf(` %s:5353 { + bufsize 1232 + errors + log . { + class error + } + rewrite stop name suffix .%s .cluster.local answer auto + forward . %s + cache 10 { + denial 9984 10 + } + }`, domain, domain, dnsIP) +} diff --git a/pkg/config/c2cc_test.go b/pkg/config/c2cc_test.go index b215027e96..1423a9fd09 100644 --- a/pkg/config/c2cc_test.go +++ b/pkg/config/c2cc_test.go @@ -2,9 +2,11 @@ package config import ( "net" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestC2CC_IsEnabled(t *testing.T) { @@ -541,3 +543,113 @@ func TestC2CC_ValidateDualStack(t *testing.T) { assert.NoError(t, cfg.C2CC.validate(cfg)) }) } + +func TestC2CC_DNSIP(t *testing.T) { + stubHostIPs(t, nil) + + t.Run("DNSIP populated when domain is set", func(t *testing.T) { + cfg := mkC2CCConfig(C2CC{ + RemoteClusters: []RemoteCluster{{ + NextHop: "10.100.0.2", + ClusterNetwork: []string{"10.45.0.0/16"}, + ServiceNetwork: []string{"10.46.0.0/16"}, + Domain: "cluster-b.remote", + }}, + }) + require.NoError(t, cfg.C2CC.validate(cfg)) + assert.Equal(t, "10.46.0.10", cfg.C2CC.Resolved[0].DNSIP) + }) + + t.Run("DNSIP empty when domain is not set", func(t *testing.T) { + cfg := mkC2CCConfig(C2CC{ + RemoteClusters: []RemoteCluster{{ + NextHop: "10.100.0.2", + ClusterNetwork: []string{"10.45.0.0/16"}, + ServiceNetwork: []string{"10.46.0.0/16"}, + }}, + }) + require.NoError(t, cfg.C2CC.validate(cfg)) + assert.Empty(t, cfg.C2CC.Resolved[0].DNSIP) + }) + + t.Run("DNSIP for IPv6 service network", func(t *testing.T) { + cfg := mkIPv6OnlyC2CCConfig(C2CC{ + RemoteClusters: []RemoteCluster{{ + NextHop: "fd00::2", + ClusterNetwork: []string{"fd03::/48"}, + ServiceNetwork: []string{"fd04::/112"}, + Domain: "cluster-b.remote", + }}, + }) + require.NoError(t, cfg.C2CC.validate(cfg)) + assert.Equal(t, "fd04::a", cfg.C2CC.Resolved[0].DNSIP) + }) +} + +func parseCIDR(t *testing.T, s string) *net.IPNet { + t.Helper() + _, ipNet, err := net.ParseCIDR(s) + require.NoError(t, err) + return ipNet +} + +func TestRenderC2CCDNSBlocks(t *testing.T) { + t.Run("no domains configured", func(t *testing.T) { + resolved := []ResolvedRemoteCluster{{ + NextHop: net.ParseIP("10.100.0.2"), + ClusterNetwork: []*net.IPNet{parseCIDR(t, "10.45.0.0/16")}, + ServiceNetwork: []*net.IPNet{parseCIDR(t, "10.46.0.0/16")}, + }} + result := RenderC2CCDNSBlocks(resolved) + assert.Empty(t, result) + }) + + t.Run("single domain", func(t *testing.T) { + resolved := []ResolvedRemoteCluster{{ + NextHop: net.ParseIP("10.100.0.2"), + ClusterNetwork: []*net.IPNet{parseCIDR(t, "10.45.0.0/16")}, + ServiceNetwork: []*net.IPNet{parseCIDR(t, "10.46.0.0/16")}, + Domain: "cluster-b.remote", + DNSIP: "10.46.0.10", + }} + result := RenderC2CCDNSBlocks(resolved) + assert.True(t, strings.HasPrefix(result, "\n"), "result should start with newline for YAML block scalar") + assert.Contains(t, result, "cluster-b.remote:5353") + assert.Contains(t, result, "rewrite stop name suffix .cluster-b.remote .cluster.local answer auto") + assert.Contains(t, result, "forward . 10.46.0.10") + assert.Contains(t, result, "denial 9984 10") + }) + + t.Run("multiple domains", func(t *testing.T) { + resolved := []ResolvedRemoteCluster{ + { + Domain: "cluster-b.remote", + DNSIP: "10.46.0.10", + }, + { + Domain: "cluster-c.remote", + DNSIP: "10.56.0.10", + }, + } + result := RenderC2CCDNSBlocks(resolved) + assert.Contains(t, result, "cluster-b.remote:5353") + assert.Contains(t, result, "forward . 10.46.0.10") + assert.Contains(t, result, "cluster-c.remote:5353") + assert.Contains(t, result, "forward . 10.56.0.10") + }) + + t.Run("mixed domain and no-domain", func(t *testing.T) { + resolved := []ResolvedRemoteCluster{ + { + Domain: "cluster-b.remote", + DNSIP: "10.46.0.10", + }, + { + Domain: "", + }, + } + result := RenderC2CCDNSBlocks(resolved) + assert.Contains(t, result, "cluster-b.remote:5353") + assert.NotContains(t, result, "cluster-c") + }) +} diff --git a/pkg/controllers/c2cc/helpers_test.go b/pkg/controllers/c2cc/helpers_test.go index 605c6251e0..474840488c 100644 --- a/pkg/controllers/c2cc/helpers_test.go +++ b/pkg/controllers/c2cc/helpers_test.go @@ -12,6 +12,7 @@ type testRemoteConfig struct { nextHop string clusterNetwork []string serviceNetwork []string + domain string } func testRemote(nextHop string, clusterNetwork, serviceNetwork []string) testRemoteConfig { @@ -22,6 +23,15 @@ func testRemote(nextHop string, clusterNetwork, serviceNetwork []string) testRem } } +func testRemoteWithDomain(nextHop string, clusterNetwork, serviceNetwork []string, domain string) testRemoteConfig { + return testRemoteConfig{ + nextHop: nextHop, + clusterNetwork: clusterNetwork, + serviceNetwork: serviceNetwork, + domain: domain, + } +} + func testConfigWithRemotes(t *testing.T, remotes ...testRemoteConfig) *config.Config { t.Helper() @@ -32,6 +42,7 @@ func testConfigWithRemotes(t *testing.T, remotes ...testRemoteConfig) *config.Co for _, r := range remotes { resolved := config.ResolvedRemoteCluster{ NextHop: net.ParseIP(r.nextHop), + Domain: r.domain, } require.NotNil(t, resolved.NextHop, "invalid nextHop: %s", r.nextHop) From 94b5de7c73f7ffda6892ed662efd06c0360d04fe Mon Sep 17 00:00:00 2001 From: Patryk Matuszak Date: Tue, 5 May 2026 11:29:20 +0200 Subject: [PATCH 2/4] Test C2CC CoreDNS forwarding --- test/resources/c2cc.resource | 15 +++ .../el9/presubmits/el98-src@c2cc.sh | 1 + test/suites/c2cc/dns.robot | 118 ++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 test/suites/c2cc/dns.robot diff --git a/test/resources/c2cc.resource b/test/resources/c2cc.resource index ac8d139e84..db244d78e8 100644 --- a/test/resources/c2cc.resource +++ b/test/resources/c2cc.resource @@ -222,3 +222,18 @@ Remove Foreign Subnet From SNAT Annotation ... Triggers a reconcile by briefly corrupting the annotation. [Arguments] ${alias} Corrupt Node SNAT Annotation On Cluster ${alias} + +Verify Corefile Contains C2CC Server Block + [Documentation] Check that the CoreDNS Corefile configmap contains a server block for the given domain. + [Arguments] ${alias} ${domain} + ${stdout}= Oc On Cluster ${alias} + ... oc get configmap dns-default -n openshift-dns -o jsonpath='{.data.Corefile}' + Should Contain ${stdout} ${domain}:5353 + Should Contain ${stdout} rewrite stop name suffix .${domain} .cluster.local answer auto + +Verify Corefile Does Not Contain C2CC Server Block + [Documentation] Check that the CoreDNS Corefile does NOT contain a server block for the given domain. + [Arguments] ${alias} ${domain} + ${stdout}= Oc On Cluster ${alias} + ... oc get configmap dns-default -n openshift-dns -o jsonpath='{.data.Corefile}' + Should Not Contain ${stdout} ${domain}:5353 diff --git a/test/scenarios-bootc/el9/presubmits/el98-src@c2cc.sh b/test/scenarios-bootc/el9/presubmits/el98-src@c2cc.sh index a5486d688f..5ab64828fa 100644 --- a/test/scenarios-bootc/el9/presubmits/el98-src@c2cc.sh +++ b/test/scenarios-bootc/el9/presubmits/el98-src@c2cc.sh @@ -115,6 +115,7 @@ scenario_run_tests() { suites/c2cc/sanity.robot \ suites/c2cc/infrastructure.robot \ suites/c2cc/connectivity.robot \ + suites/c2cc/dns.robot \ suites/c2cc/reconciliation.robot \ suites/c2cc/cleanup.robot } diff --git a/test/suites/c2cc/dns.robot b/test/suites/c2cc/dns.robot new file mode 100644 index 0000000000..174eb33c0d --- /dev/null +++ b/test/suites/c2cc/dns.robot @@ -0,0 +1,118 @@ +*** Settings *** +Documentation Cross-cluster DNS tests for C2CC. +... Verifies CoreDNS server blocks are injected for remote domains, +... DNS resolution works across clusters, and service access via +... DNS names works end-to-end. + +Resource ../../resources/microshift-process.resource +Resource ../../resources/kubeconfig.resource +Resource ../../resources/oc.resource +Resource ../../resources/c2cc.resource + +Suite Setup Setup +Suite Teardown Teardown + +Test Tags c2cc + + +*** Variables *** +${NAMESPACE} c2cc-dns-test + + +*** Test Cases *** +Corefile Contains C2CC Server Block On Cluster A + [Documentation] Verify Cluster A's Corefile has a server block for Cluster B's domain. + Verify Corefile Contains C2CC Server Block cluster-a ${CLUSTER_B_DOMAIN} + +Corefile Contains C2CC Server Block On Cluster B + [Documentation] Verify Cluster B's Corefile has a server block for Cluster A's domain. + Verify Corefile Contains C2CC Server Block cluster-b ${CLUSTER_A_DOMAIN} + +Resolve Remote Service DNS From Cluster A + [Documentation] Verify pod on Cluster A can resolve a service on Cluster B via DNS. + DNS Resolve From Cluster cluster-a + ... hello-microshift.${NAMESPACE}.svc.${CLUSTER_B_DOMAIN} + +Resolve Remote Service DNS From Cluster B + [Documentation] Verify pod on Cluster B can resolve a service on Cluster A via DNS. + DNS Resolve From Cluster cluster-b + ... hello-microshift.${NAMESPACE}.svc.${CLUSTER_A_DOMAIN} + +Curl Remote Service Via DNS From Cluster A + [Documentation] Verify pod on Cluster A can reach a service on Cluster B using the remote DNS name. + Curl DNS From Cluster cluster-a + ... hello-microshift.${NAMESPACE}.svc.${CLUSTER_B_DOMAIN} 8080 + +Curl Remote Service Via DNS From Cluster B + [Documentation] Verify pod on Cluster B can reach a service on Cluster A using the remote DNS name. + Curl DNS From Cluster cluster-b + ... hello-microshift.${NAMESPACE}.svc.${CLUSTER_A_DOMAIN} 8080 + + +*** Keywords *** +Setup + [Documentation] Set up clusters and deploy test workloads on both. + Check Required Env Variables + Login MicroShift Host + Setup Kubeconfig + Register Local Cluster cluster-a + Register Remote Cluster cluster-b ${HOST2_IP} ${HOST2_SSH_PORT} ${KUBECONFIG_B} + Deploy DNS Test Workloads + +Teardown + [Documentation] Remove test workloads and close connections. + Cleanup DNS Test Workloads + Teardown All Remote Clusters + Remove Kubeconfig + Logout MicroShift Host + +Deploy DNS Test Workloads + [Documentation] Create namespace and deploy hello-microshift + curl-pod on both clusters. + VAR ${assets}= ${EXECDIR}/assets/c2cc + FOR ${alias} IN cluster-a cluster-b + Oc On Cluster ${alias} oc create namespace ${NAMESPACE} + Oc On Cluster ${alias} oc apply -n ${NAMESPACE} -f ${assets}/hello-microshift.yaml + Oc On Cluster ${alias} oc apply -n ${NAMESPACE} -f ${assets}/curl-pod.yaml + END + Wait For DNS Test Pods + +Wait For DNS Test Pods + [Documentation] Wait for all test pods to be Ready on both clusters. + FOR ${alias} IN cluster-a cluster-b + Oc On Cluster ${alias} + ... oc wait pod/hello-microshift pod/curl-pod -n ${NAMESPACE} --for=condition=Ready --timeout=120s + END + +Cleanup DNS Test Workloads + [Documentation] Delete test namespace on both clusters. Ignores errors. + FOR ${alias} IN cluster-a cluster-b + Run Keyword And Ignore Error + ... Oc On Cluster ${alias} oc delete namespace ${NAMESPACE} --timeout=60s + END + +DNS Resolve From Cluster + [Documentation] Resolve a DNS name from curl-pod on the given cluster. Retries for up to 60s. + [Arguments] ${alias} ${fqdn} + Wait Until Keyword Succeeds 12x 5s + ... DNS Lookup Should Succeed ${alias} ${fqdn} + +DNS Lookup Should Succeed + [Documentation] Resolve a DNS name from curl-pod using getent hosts. + [Arguments] ${alias} ${fqdn} + ${stdout}= Oc On Cluster ${alias} + ... oc exec curl-pod -n ${NAMESPACE} -- getent hosts ${fqdn} + Should Not Be Empty ${stdout} + +Curl DNS From Cluster + [Documentation] Curl a service by DNS name from curl-pod on the given cluster. + [Arguments] ${alias} ${fqdn} ${port} + Wait Until Keyword Succeeds 12x 5s + ... Curl DNS Should Succeed ${alias} ${fqdn} ${port} + +Curl DNS Should Succeed + [Documentation] Single attempt to curl a DNS name from curl-pod. + [Arguments] ${alias} ${fqdn} ${port} + ${stdout}= Oc On Cluster ${alias} + ... oc exec curl-pod -n ${NAMESPACE} -- curl -sS --max-time 10 http://${fqdn}:${port} + Should Contain ${stdout} Hello MicroShift + RETURN ${stdout} From 1a5258bd1b25b04ae25be6e7acd1aa4877935dc3 Mon Sep 17 00:00:00 2001 From: Patryk Matuszak Date: Mon, 4 May 2026 20:19:05 +0200 Subject: [PATCH 3/4] Make sure the IP is preserved --- test/assets/c2cc/hello-microshift.yaml | 19 ++++++++++- test/suites/c2cc/connectivity.robot | 45 +++++++++++++++++++++++--- test/suites/c2cc/dns.robot | 13 +++++--- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/test/assets/c2cc/hello-microshift.yaml b/test/assets/c2cc/hello-microshift.yaml index 6169449b78..34ffcb717f 100644 --- a/test/assets/c2cc/hello-microshift.yaml +++ b/test/assets/c2cc/hello-microshift.yaml @@ -10,10 +10,27 @@ spec: - name: hello-microshift image: quay.io/microshift/busybox:1.36 command: ["/bin/sh"] - args: ["-c", "while true; do echo -ne \"HTTP/1.0 200 OK\r\nContent-Length: 16\r\n\r\nHello MicroShift\" | nc -l -p 8080 ; done"] + args: + - -c + - | + mkdir -p /tmp/www/cgi-bin + cat > /tmp/www/cgi-bin/hello <