diff --git a/cmd/atenet/internal/dns/README.md b/cmd/atenet/internal/dns/README.md index a134e829ab..e89b06b62f 100644 --- a/cmd/atenet/internal/dns/README.md +++ b/cmd/atenet/internal/dns/README.md @@ -10,7 +10,6 @@ Cluster resources: * Deployment `ate-system:dns`. Label: app=dns * Service `ate-system:dns`. -* ConfigMap `ate-system:dns`. These are defined in manifests/ate-install/atenet-dns.yaml. @@ -20,16 +19,33 @@ These are defined in manifests/ate-install/atenet-dns.yaml. * Deployment `ate-system:dns`. * Service `ate-system:dns` pointing to the Deployment. -ConfigMap `ate-system:dns`: +Corefile, rendered by `corefile.go`: ``` -# Match any 'A' query for an actor name + atespace pattern under actors.resources.substrate.ate.dev +# Answer any 'A' query for an actor name + atespace pattern under actors.resources.substrate.ate.dev template IN A actors.resources.substrate.ate.dev { match "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.actors\\.resources\\.substrate\\.ate\\.dev\\.$" answer "{{ .Name }} 60 IN A " + fallthrough + } +# NODATA for a well-formed actor name on any other qtype (AAAA, HTTPS, SRV, ...). + template ANY ANY actors.resources.substrate.ate.dev { + match "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.actors\\.resources\\.substrate\\.ate\\.dev\\.$" + rcode NOERROR + authority "{{ .Zone }} 60 IN SOA ns.dns.{{ .Zone }} hostmaster.{{ .Zone }} (1 60 60 60 60)" + fallthrough + } +# Terminal catch-all: NXDOMAIN for anything else in the zone. + template ANY ANY actors.resources.substrate.ate.dev { + rcode NXDOMAIN + authority "{{ .Zone }} 60 IN SOA ns.dns.{{ .Zone }} hostmaster.{{ .Zone }} (1 60 60 60 60)" } ``` +The last two blocks keep the zone from ever answering SERVFAIL, which musl libc +maps to `EAI_AGAIN` — sinking the paired A query with it — and which cannot be +cached negatively. + ## Integration * CoreDNS: Update CoreDNS ConfigMap to add the stub resolver. diff --git a/cmd/atenet/internal/dns/corefile.go b/cmd/atenet/internal/dns/corefile.go index 0b301e7e29..b8da40dee8 100644 --- a/cmd/atenet/internal/dns/corefile.go +++ b/cmd/atenet/internal/dns/corefile.go @@ -30,7 +30,8 @@ func init() { } func buildTemplate() string { - // Build up the corefileTemplate programmatically to make it easier to understand. + const soaDirective = ` authority "{{ .Zone }} 60 IN SOA ns.dns.{{ .Zone }} hostmaster.{{ .Zone }} (1 60 60 60 60)"` + var directives []string // Plugins to enable. directives = append(directives, "log") @@ -44,12 +45,31 @@ func buildTemplate() string { directives = append(directives, fmt.Sprintf("template IN A %s {", resources.ActorDNSSuffix)) // Escape the suffix's dots so they match literally; the final \. matches the FQDN's trailing dot. escapedSuffix := strings.ReplaceAll(resources.ActorDNSSuffix, ".", `\.`) - directives = append(directives, fmt.Sprintf(` match "^%s\.%s\.%s\.$"`, resources.ResourceNameRegexPattern, resources.ResourceNameRegexPattern, escapedSuffix)) + actorMatch := fmt.Sprintf(` match "^%s\.%s\.%s\.$"`, resources.ResourceNameRegexPattern, resources.ResourceNameRegexPattern, escapedSuffix) + directives = append(directives, actorMatch) // Note the %s -- this will be filled with the router IP. directives = append(directives, ` answer "{{ .Name }} 60 IN A %s"`) + // Without fallthrough a regex miss answers SERVFAIL instead of reaching the + // next block. + directives = append(directives, " fallthrough") + directives = append(directives, "}") + + // Valid actor names return NOERROR (NODATA) for non-A queries. + directives = append(directives, fmt.Sprintf("template ANY ANY %s {", resources.ActorDNSSuffix)) + directives = append(directives, actorMatch) + directives = append(directives, " rcode NOERROR") + directives = append(directives, soaDirective) + directives = append(directives, " fallthrough") + directives = append(directives, "}") + + // Returns rcode NXDOMAIN (Non-Existent Domain) for any query that did not + // match the valid actor regex in the previous blocks. + // TODO(#922): answer empty non-terminals with NODATA. + directives = append(directives, fmt.Sprintf("template ANY ANY %s {", resources.ActorDNSSuffix)) + directives = append(directives, " rcode NXDOMAIN") + directives = append(directives, soaDirective) directives = append(directives, "}") - // Generate the template. b := strings.Builder{} fmt.Fprintf(&b, "# Generated at %s\n", time.Now()) fmt.Fprintf(&b, "%s:53 {\n ", resources.ActorDNSSuffix) diff --git a/cmd/atenet/internal/dns/corefile_test.go b/cmd/atenet/internal/dns/corefile_test.go index f13429e475..c2d6308fb4 100644 --- a/cmd/atenet/internal/dns/corefile_test.go +++ b/cmd/atenet/internal/dns/corefile_test.go @@ -15,50 +15,61 @@ package dns import ( + "fmt" "strings" "testing" - - "github.com/agent-substrate/substrate/internal/resources" ) +// Spelled out literally so a change to the name regex or DNS suffix fails this test. +const wantCorefileFmt = `actors.resources.substrate.ate.dev:53 { + log + errors + health :8080 + ready :8181 + reload + template IN A actors.resources.substrate.ate.dev { + match "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\.actors\.resources\.substrate\.ate\.dev\.$" + answer "{{ .Name }} 60 IN A %s" + fallthrough + } + template ANY ANY actors.resources.substrate.ate.dev { + match "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\.[a-z0-9]([-a-z0-9]*[a-z0-9])?\.actors\.resources\.substrate\.ate\.dev\.$" + rcode NOERROR + authority "{{ .Zone }} 60 IN SOA ns.dns.{{ .Zone }} hostmaster.{{ .Zone }} (1 60 60 60 60)" + fallthrough + } + template ANY ANY actors.resources.substrate.ate.dev { + rcode NXDOMAIN + authority "{{ .Zone }} 60 IN SOA ns.dns.{{ .Zone }} hostmaster.{{ .Zone }} (1 60 60 60 60)" + } +} +` + +// zoneBody strips the "# Generated at " header. +func zoneBody(t *testing.T, corefile string) string { + t.Helper() + header, body, ok := strings.Cut(corefile, "\n") + if !ok || !strings.HasPrefix(header, "# Generated at ") { + t.Fatalf("makeCoreFile() has no generated-at header, got first line %q", header) + } + return body +} + func TestMakeCoreFile(t *testing.T) { tests := []struct { name string routerIP string - expected []string }{ - { - name: "standard local IP", - routerIP: "10.240.0.10", - expected: []string{ - "actors.resources.substrate.ate.dev:53 {", - "log", - "errors", - "health :8080", - "ready :8181", - "reload", - "template IN A actors.resources.substrate.ate.dev {", - `match "^` + resources.ResourceNameRegexPattern + `\.` + resources.ResourceNameRegexPattern + `\.actors\.resources\.substrate\.ate\.dev\.$"`, - `answer "{{ .Name }} 60 IN A 10.240.0.10"`, - }, - }, - { - name: "different IP", - routerIP: "192.168.1.1", - expected: []string{ - "actors.resources.substrate.ate.dev:53 {", - `answer "{{ .Name }} 60 IN A 192.168.1.1"`, - }, - }, + {name: "cluster IP", routerIP: "10.240.0.10"}, + {name: "different cluster IP", routerIP: "192.168.1.1"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := makeCoreFile(tc.routerIP) - for _, exp := range tc.expected { - if !strings.Contains(got, exp) { - t.Errorf("makeCoreFile(%q) missing expected substring %q\nGot:\n%s", tc.routerIP, exp, got) - } + got := zoneBody(t, makeCoreFile(tc.routerIP)) + want := fmt.Sprintf(wantCorefileFmt, tc.routerIP) + if got != want { + t.Errorf("makeCoreFile(%q) rendered an unexpected Corefile\nGot:\n%s\nWant:\n%s", tc.routerIP, got, want) } }) } diff --git a/cmd/atenet/internal/dns/dns.go b/cmd/atenet/internal/dns/dns.go index cf2db99b69..86e13eddc7 100644 --- a/cmd/atenet/internal/dns/dns.go +++ b/cmd/atenet/internal/dns/dns.go @@ -26,6 +26,7 @@ import ( "syscall" "time" + "github.com/agent-substrate/substrate/internal/atenetconsts" "github.com/agent-substrate/substrate/internal/resources" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -33,12 +34,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -const ( - // serviceName is the name of the CoreDNS service. - serviceName = "dns" - systemNamespace = "ate-system" -) - // Controller manages the DNS configuration for the ATE. type Controller struct { Client client.Client @@ -73,7 +68,7 @@ func (c *Controller) reconcile(ctx context.Context) error { // 1. Get the ClusterIP of atenet-router in ate-system namespace routerSvc := &corev1.Service{} - if err := c.Client.Get(ctx, types.NamespacedName{Name: "atenet-router", Namespace: systemNamespace}, routerSvc); err != nil { + if err := c.Client.Get(ctx, types.NamespacedName{Name: atenetconsts.RouterService, Namespace: atenetconsts.NamespaceATESystem}, routerSvc); err != nil { if errors.IsNotFound(err) { slog.WarnContext(ctx, "atenet-router service not found, skipping until it is available") return nil @@ -89,7 +84,7 @@ func (c *Controller) reconcile(ctx context.Context) error { // 2. Get the ClusterIP of dns service in ate-system namespace dnsSvc := &corev1.Service{} - if err := c.Client.Get(ctx, types.NamespacedName{Name: serviceName, Namespace: systemNamespace}, dnsSvc); err != nil { + if err := c.Client.Get(ctx, types.NamespacedName{Name: atenetconsts.DNSService, Namespace: atenetconsts.NamespaceATESystem}, dnsSvc); err != nil { if errors.IsNotFound(err) { slog.WarnContext(ctx, "dns service not found, skipping until it is available") return nil diff --git a/internal/atenetconsts/atenetconsts.go b/internal/atenetconsts/atenetconsts.go new file mode 100644 index 0000000000..682b9b7f55 --- /dev/null +++ b/internal/atenetconsts/atenetconsts.go @@ -0,0 +1,25 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package atenetconsts holds the cluster object names atenet and the e2e suites share. +package atenetconsts + +const ( + // NamespaceATESystem is the namespace the ATE system components run in. + NamespaceATESystem = "ate-system" + // RouterService is the atenet router Service. + RouterService = "atenet-router" + // DNSService is the atenet CoreDNS Service. + DNSService = "dns" +) diff --git a/internal/e2e/netutil/clusterip.go b/internal/e2e/netutil/clusterip.go new file mode 100644 index 0000000000..bb4c4378bc --- /dev/null +++ b/internal/e2e/netutil/clusterip.go @@ -0,0 +1,58 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package netutil holds DNS and address-family helpers for the e2e suites. +package netutil + +import ( + "net/netip" + + corev1 "k8s.io/api/core/v1" +) + +// ServiceClusterIPs holds a Service's cluster IPs split by family; a family +// the Service does not have is the empty string. +type ServiceClusterIPs struct { + V4 string + V6 string +} + +// ClusterIPsByFamily gets the Service's cluster IPs split by IPv4 and IPv6. +func ClusterIPsByFamily(svc *corev1.Service) ServiceClusterIPs { + var ips ServiceClusterIPs + if svc == nil { + return ips + } + addrs := svc.Spec.ClusterIPs + if len(addrs) == 0 && svc.Spec.ClusterIP != "" { + addrs = []string{svc.Spec.ClusterIP} + } + for _, ip := range addrs { + if ip == "" || ip == corev1.ClusterIPNone { + continue + } + addr, err := netip.ParseAddr(ip) + if err != nil { + continue + } + switch { + case addr.Is4() && ips.V4 == "": + ips.V4 = ip + // A v4-mapped v6 address counts as neither family. + case addr.Is6() && !addr.Is4In6() && ips.V6 == "": + ips.V6 = ip + } + } + return ips +} diff --git a/internal/e2e/netutil/clusterip_test.go b/internal/e2e/netutil/clusterip_test.go new file mode 100644 index 0000000000..78d447d21f --- /dev/null +++ b/internal/e2e/netutil/clusterip_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package netutil + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestClusterIPsByFamily(t *testing.T) { + for _, tc := range []struct { + name string + spec corev1.ServiceSpec + want4 string + want6 string + }{ + { + name: "single stack IPv4", + spec: corev1.ServiceSpec{ClusterIP: "10.96.0.10", ClusterIPs: []string{"10.96.0.10"}}, + want4: "10.96.0.10", + }, + { + name: "single stack IPv6", + spec: corev1.ServiceSpec{ClusterIP: "fd00:10:96::a", ClusterIPs: []string{"fd00:10:96::a"}}, + want6: "fd00:10:96::a", + }, + { + name: "dual stack IPv4 primary", + spec: corev1.ServiceSpec{ClusterIP: "10.96.0.10", ClusterIPs: []string{"10.96.0.10", "fd00:10:96::a"}}, + want4: "10.96.0.10", + want6: "fd00:10:96::a", + }, + { + name: "dual stack IPv6 primary", + spec: corev1.ServiceSpec{ClusterIP: "fd00:10:96::a", ClusterIPs: []string{"fd00:10:96::a", "10.96.0.10"}}, + want4: "10.96.0.10", + want6: "fd00:10:96::a", + }, + { + // A Service built by hand or by a fake client may only set the scalar. + name: "scalar ClusterIP only", + spec: corev1.ServiceSpec{ClusterIP: "10.96.0.10"}, + want4: "10.96.0.10", + }, + { + name: "headless", + spec: corev1.ServiceSpec{ClusterIP: corev1.ClusterIPNone, ClusterIPs: []string{corev1.ClusterIPNone}}, + }, + { + name: "no cluster IP", + spec: corev1.ServiceSpec{}, + }, + { + name: "unparseable entry is skipped", + spec: corev1.ServiceSpec{ClusterIPs: []string{"not-an-ip", "", "10.96.0.10"}}, + want4: "10.96.0.10", + }, + { + // A v4-mapped v6 address counts as neither family: net.IP.To4 would + // file it as IPv4, and calling it IPv6 would claim a dual-stack + // Service the cluster does not have. + name: "v4-mapped v6 counts as neither", + spec: corev1.ServiceSpec{ClusterIPs: []string{"::ffff:10.96.0.10"}}, + }, + { + name: "first entry per family wins", + spec: corev1.ServiceSpec{ClusterIPs: []string{"10.96.0.10", "10.96.0.11", "fd00:10:96::a", "fd00:10:96::b"}}, + want4: "10.96.0.10", + want6: "fd00:10:96::a", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := ClusterIPsByFamily(&corev1.Service{Spec: tc.spec}) + if got.V4 != tc.want4 || got.V6 != tc.want6 { + t.Errorf("ClusterIPsByFamily() = (%q, %q), want (%q, %q)", got.V4, got.V6, tc.want4, tc.want6) + } + }) + } +} + +func TestClusterIPsByFamilyNilService(t *testing.T) { + if got := ClusterIPsByFamily(nil); got != (ServiceClusterIPs{}) { + t.Errorf("ClusterIPsByFamily(nil) = %+v, want zero value", got) + } +} diff --git a/internal/e2e/netutil/dns.go b/internal/e2e/netutil/dns.go new file mode 100644 index 0000000000..8dc82b69ee --- /dev/null +++ b/internal/e2e/netutil/dns.go @@ -0,0 +1,136 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package netutil + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/atenetconsts" + "github.com/agent-substrate/substrate/internal/portforward" + "k8s.io/client-go/kubernetes" +) + +// DNSRcode is how the server answered, at the granularity net.Resolver exposes. +type DNSRcode int + +const ( + // DNSAnswered is NOERROR with at least one address of the queried family. + DNSAnswered DNSRcode = iota + // DNSEmpty is NODATA or NXDOMAIN: the name has no address in this family. + DNSEmpty + // DNSFailed is SERVFAIL, REFUSED, a timeout, or a malformed reply. + DNSFailed +) + +func (r DNSRcode) String() string { + switch r { + case DNSAnswered: + return "answered" + case DNSEmpty: + return "no-such-host (NODATA or NXDOMAIN)" + case DNSFailed: + return "server failure (SERVFAIL/REFUSED/timeout)" + default: + return "unknown" + } +} + +// DNSClient resolves names against the ate-system/dns CoreDNS Service over a +// port-forward, rather than through the cluster resolver, because the kube-dns +// delegation only exists on GKE. +type DNSClient struct { + resolver *net.Resolver + stop func() +} + +// NewDNSClient establishes a port-forward to the atenet DNS Service. Call Close +// to tear it down. +func NewDNSClient(ctx context.Context, kubeconfig, kubecontext string) (*DNSClient, error) { + config, err := ateclient.LoadConfig(kubeconfig, kubecontext) + if err != nil { + return nil, fmt.Errorf("loading kubeconfig: %w", err) + } + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("creating k8s client: %w", err) + } + + localPort, stop, err := portforward.ServicePortForward(ctx, config, clientset, atenetconsts.NamespaceATESystem, atenetconsts.DNSService, 53) + if err != nil { + return nil, err + } + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(localPort)) + + return &DNSClient{ + stop: stop, + resolver: &net.Resolver{ + // cgo's resolver would ignore Dial and query the host's nameservers. + PreferGo: true, + // Surface a per-family failure instead of hiding it behind the + // other family's success. + StrictErrors: true, + Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + // net.Resolver uses stream framing for any conn that is not a + // net.PacketConn, so the TCP tunnel is transparent to it. + var d net.Dialer + return d.DialContext(ctx, "tcp", addr) + }, + }, + }, nil +} + +// Close tears down the port-forward. +func (c *DNSClient) Close() { + if c.stop != nil { + c.stop() + } +} + +// Lookup resolves name in a single address family — network is "ip4" for an A +// query, "ip6" for AAAA. DNSFailed carries the underlying error; DNSEmpty does +// not, because it is a valid answer. +func (c *DNSClient) Lookup(ctx context.Context, network, name string) ([]string, DNSRcode, error) { + // Root the name so the resolver skips the host's search list and ndots + // handling, which would otherwise make the query depend on where the test + // runs. + if !strings.HasSuffix(name, ".") { + name += "." + } + + lookupCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + addrs, err := c.resolver.LookupNetIP(lookupCtx, network, name) + if err == nil { + ips := make([]string, 0, len(addrs)) + for _, a := range addrs { + ips = append(ips, a.Unmap().String()) + } + return ips, DNSAnswered, nil + } + + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return nil, DNSEmpty, nil + } + return nil, DNSFailed, fmt.Errorf("%s query for %q: %w", network, name, err) +} diff --git a/internal/e2e/router_client.go b/internal/e2e/router_client.go index 7a9e536079..0181c94f76 100644 --- a/internal/e2e/router_client.go +++ b/internal/e2e/router_client.go @@ -29,6 +29,7 @@ import ( "time" "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/atenetconsts" "github.com/agent-substrate/substrate/internal/portforward" "github.com/agent-substrate/substrate/internal/resources" "k8s.io/client-go/kubernetes" @@ -36,28 +37,22 @@ import ( ) const ( - routerNamespace = "ate-system" - routerService = "atenet-router" - // routerConnectServicePort is atenet-router's Service port for - // CONNECT-tunneled traffic (see manifests/ate-install/atenet-router.yaml). - // It is a distinct listener from the plain HTTP one Get/PostJSON use: - // atenet-router's ingress_http_listener never enables the CONNECT method, - // only connect_terminate does. + // routerConnectServicePort is atenet-router's CONNECT listener port (see + // manifests/ate-install/atenet-router.yaml); the plain HTTP listener does + // not enable the CONNECT method. routerConnectServicePort = 8081 ) // RouterClient sends HTTP requests to actors through the ingress atenet-router, the // same way real traffic arrives (so the request is routed and, if needed, the -// actor is resumed). It port-forwards the router Service, mirroring the -// approach in internal/ateclient. +// actor is resumed). It port-forwards the router Service. type RouterClient struct { baseURL string http *http.Client stop func() - // config/clientset are retained to lazily open a second port-forward, to - // routerConnectServicePort, only if Connect is ever called -- most callers - // never CONNECT, so the plain HTTP one from NewRouterClient covers them. + // config/clientset are retained to open the CONNECT port-forward lazily on + // first Connect. config *rest.Config clientset kubernetes.Interface @@ -79,7 +74,7 @@ func NewRouterClient(ctx context.Context) (*RouterClient, error) { return nil, fmt.Errorf("creating k8s client: %w", err) } - localPort, stop, err := portforward.ServicePortForward(ctx, config, clientset, routerNamespace, routerService, 80) + localPort, stop, err := portforward.ServicePortForward(ctx, config, clientset, atenetconsts.NamespaceATESystem, atenetconsts.RouterService, 80) if err != nil { return nil, err } @@ -131,14 +126,9 @@ func (c *RouterClient) request(ctx context.Context, method string, actorRef reso return c.http.Do(req) } -// Connect opens a CONNECT tunnel through the router to port on actorRef, -// exercising atenet-router's arbitrary-port ingress support: the target port -// travels in the CONNECT authority (e.g. "my-actor.team-a...:9090"), the same -// way a real client reaches a port other than an actor's primary one. On a -// non-2xx response the returned error carries the status and body, mirroring -// atunnel.Client.DialContext's handling of the same failure mode on the -// egress side. The caller owns the returned connection and must Close it; -// the underlying port-forward is torn down by RouterClient.Close. +// Connect opens a CONNECT tunnel through the router to port on actorRef; the +// target port travels in the CONNECT authority. The caller owns the returned +// connection; RouterClient.Close tears down the underlying port-forward. func (c *RouterClient) Connect(ctx context.Context, actorRef resources.ActorRef, port int) (net.Conn, error) { if err := c.ensureConnectPortForward(ctx); err != nil { return nil, err @@ -188,7 +178,7 @@ func (c *RouterClient) Connect(ctx context.Context, actorRef resources.ActorRef, // in one test don't each pay for a fresh port-forward. func (c *RouterClient) ensureConnectPortForward(ctx context.Context) error { c.connectOnce.Do(func() { - localPort, stop, err := portforward.ServicePortForward(ctx, c.config, c.clientset, routerNamespace, routerService, routerConnectServicePort) + localPort, stop, err := portforward.ServicePortForward(ctx, c.config, c.clientset, atenetconsts.NamespaceATESystem, atenetconsts.RouterService, routerConnectServicePort) if err != nil { c.connectErr = fmt.Errorf("port-forwarding to the router's CONNECT listener: %w", err) return @@ -200,8 +190,7 @@ func (c *RouterClient) ensureConnectPortForward(ctx context.Context) error { } // bufferedConn recovers bytes http.ReadResponse buffered past the header -// boundary, mirroring internal/atunnel/client.go's identical need on the -// egress CONNECT client. +// boundary. type bufferedConn struct { net.Conn reader *bufio.Reader diff --git a/internal/e2e/statusz.go b/internal/e2e/statusz.go index a04a07141d..ad89bcadb6 100644 --- a/internal/e2e/statusz.go +++ b/internal/e2e/statusz.go @@ -22,6 +22,7 @@ import ( "time" "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/atenetconsts" "github.com/agent-substrate/substrate/internal/portforward" "k8s.io/client-go/kubernetes" ) @@ -51,7 +52,7 @@ func NewStatuszClient(ctx context.Context) (*StatuszClient, error) { return nil, fmt.Errorf("creating k8s client: %w", err) } - localPort, stop, err := portforward.ServicePortForward(ctx, config, clientset, routerNamespace, routerService, routerStatusPort) + localPort, stop, err := portforward.ServicePortForward(ctx, config, clientset, atenetconsts.NamespaceATESystem, atenetconsts.RouterService, routerStatusPort) if err != nil { return nil, err } diff --git a/internal/e2e/suites/networking/dns_test.go b/internal/e2e/suites/networking/dns_test.go new file mode 100644 index 0000000000..8e2fc1383b --- /dev/null +++ b/internal/e2e/suites/networking/dns_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package networking + +import ( + "context" + "slices" + "testing" + + "github.com/agent-substrate/substrate/internal/atenetconsts" + "github.com/agent-substrate/substrate/internal/e2e" + "github.com/agent-substrate/substrate/internal/e2e/netutil" + "github.com/agent-substrate/substrate/internal/resources" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The actor zone is served by a CoreDNS `template` block, which answers for any +// name matching .. whether or not that actor exists. +// These tests therefore need no actor fixture — they are asserting the zone's +// behavior, not an actor's. +func probeActorDNSName() string { + return resources.ActorDNSName(resources.ActorRef{Atespace: networkingAtespace, Name: "dns-probe"}) +} + +func mustDNSClient(t *testing.T, ctx context.Context) *netutil.DNSClient { + t.Helper() + dns, err := netutil.NewDNSClient(ctx, e2e.KubeConfig, e2e.KubeContext) + if err != nil { + t.Fatalf("NewDNSClient: %v", err) + } + t.Cleanup(dns.Close) + return dns +} + +// TestActorDNSZone asserts that the actor zone answers an A query with the +// router's ClusterIP, and that everything else it is asked comes back NODATA or +// NXDOMAIN rather than SERVFAIL. cmd/atenet/internal/dns/README.md explains why +// the rcode matters. Family-agnostic: it runs, not skips, on single-stack. +func TestActorDNSZone(t *testing.T) { + ctx := context.Background() + dns := mustDNSClient(t, ctx) + + routerSvc, err := e2e.GetClients().K8s.CoreV1().Services(atenetconsts.NamespaceATESystem).Get(ctx, atenetconsts.RouterService, metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting Service %s/%s: %v", atenetconsts.NamespaceATESystem, atenetconsts.RouterService, err) + } + routerIPs := netutil.ClusterIPsByFamily(routerSvc) + + name := probeActorDNSName() + + t.Run("A answers with the router ClusterIP", func(t *testing.T) { + if routerIPs.V4 == "" { + // On a v6-only cluster emitting an A record at all would be the bug. + t.Skip("atenet-router has no IPv4 ClusterIP") + } + addrs, rcode, err := dns.Lookup(ctx, "ip4", name) + if rcode != netutil.DNSAnswered { + t.Fatalf("A %s: %v (%v); want the router ClusterIP %s", name, rcode, err, routerIPs.V4) + } + if !slices.Contains(addrs, routerIPs.V4) { + t.Fatalf("A %s = %v; want it to contain the atenet-router ClusterIP %s", name, addrs, routerIPs.V4) + } + }) + + t.Run("AAAA is not a server failure", func(t *testing.T) { + // The name is well-formed, so NODATA is the answer owed on a + // single-stack cluster: it exists, it just has no AAAA. + _, rcode, err := dns.Lookup(ctx, "ip6", name) + if rcode == netutil.DNSFailed { + t.Fatalf("AAAA %s: %v (%v); want NODATA — see cmd/atenet/internal/dns/README.md", name, rcode, err) + } + }) + + t.Run("a name outside the actor pattern is not a server failure", func(t *testing.T) { + // A regex miss needs both `fallthrough`s and the terminal catch-all + // template to become NXDOMAIN; drop either and this goes red. + bogus := "not-an-actor." + resources.ActorDNSSuffix + _, rcode, err := dns.Lookup(ctx, "ip4", bogus) + if rcode == netutil.DNSFailed { + t.Fatalf("A %s: %v (%v); want NXDOMAIN", bogus, rcode, err) + } + }) +}