Skip to content
Closed
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
40 changes: 40 additions & 0 deletions internal/atenet/headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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 atenet defines the shared contract for Substrate actor networking.
package atenet

import (
"fmt"
"strings"

"github.com/agent-substrate/substrate/internal/resources"
)

const (
// TargetActorHeader identifies the actor selected for ingress routing as
// "<atespace>/<actor>". HTTP field names are case-insensitive; this uses its
// HTTP/2 wire form so dataplane configuration and metadata are native.
TargetActorHeader = "ate-target-actor"
)

// ParseTargetActor parses and validates a TargetActorHeader value.
func ParseTargetActor(value string) (resources.ActorRef, error) {
atespace, actorName, ok := strings.Cut(value, "/")
if !ok || strings.Contains(actorName, "/") ||
!resources.IsValidResourceName(atespace) || !resources.IsValidResourceName(actorName) {
return resources.ActorRef{}, fmt.Errorf("invalid actor reference %q", value)
}
return resources.ActorRef{Atespace: atespace, Name: actorName}, nil
}
47 changes: 47 additions & 0 deletions internal/atenet/headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// 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 atenet

import "testing"

func TestParseTargetActor(t *testing.T) {
tests := []struct {
name string
value string
wantAtespace string
wantActorName string
wantErr bool
}{
{name: "valid", value: "team-a/actor-1", wantAtespace: "team-a", wantActorName: "actor-1"},
{name: "missing separator", value: "team-a", wantErr: true},
{name: "extra separator", value: "team-a/actor-1/extra", wantErr: true},
{name: "empty atespace", value: "/actor-1", wantErr: true},
{name: "empty actor", value: "team-a/", wantErr: true},
{name: "invalid atespace", value: "TEAM-A/actor-1", wantErr: true},
{name: "invalid actor", value: "team-a/ACTOR-1", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseTargetActor(tt.value)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseTargetActor(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
}
if got.Atespace != tt.wantAtespace || got.Name != tt.wantActorName {
t.Errorf("ParseTargetActor(%q) = %q, want %q/%q", tt.value, got.String(), tt.wantAtespace, tt.wantActorName)
}
})
}
}
12 changes: 7 additions & 5 deletions internal/e2e/router_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"time"

"github.com/agent-substrate/substrate/internal/ateclient"
"github.com/agent-substrate/substrate/internal/atenet"
"github.com/agent-substrate/substrate/internal/portforward"
"github.com/agent-substrate/substrate/internal/resources"
"k8s.io/client-go/kubernetes"
Expand Down Expand Up @@ -106,8 +107,7 @@ func (c *RouterClient) BaseURL() string {
return c.baseURL
}

// Get issues GET path to actor through the router, setting the actor's DNS Host
// so the router routes (and resumes) it. The caller must close the body.
// Get issues GET path to actor through the router. The caller must close the body.
func (c *RouterClient) Get(ctx context.Context, actorRef resources.ActorRef, path string) (*http.Response, error) {
return c.request(ctx, http.MethodGet, actorRef, path, nil)
}
Expand All @@ -126,8 +126,7 @@ func (c *RouterClient) request(ctx context.Context, method string, actorRef reso
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
// The router routes on the Host/:authority, not a header.
req.Host = resources.ActorDNSName(actorRef)
req.Header.Set(atenet.TargetActorHeader, actorRef.String())
return c.http.Do(req)
}

Expand All @@ -149,11 +148,14 @@ func (c *RouterClient) Connect(ctx context.Context, actorRef resources.ActorRef,
return nil, fmt.Errorf("connecting to router's CONNECT listener: %w", err)
}

destination := net.JoinHostPort(resources.ActorDNSName(actorRef), strconv.Itoa(port))
destination := net.JoinHostPort(actorRef.Name, strconv.Itoa(port))
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Host: destination},
Host: destination,
Header: http.Header{
atenet.TargetActorHeader: []string{actorRef.String()},
},
}
if err := req.Write(rawConn); err != nil {
_ = rawConn.Close()
Expand Down
3 changes: 2 additions & 1 deletion internal/e2e/suites/demo/demo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"time"

"github.com/agent-substrate/substrate/internal/ateclient"
"github.com/agent-substrate/substrate/internal/atenet"
"github.com/agent-substrate/substrate/internal/e2e"
"github.com/agent-substrate/substrate/internal/resources"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
Expand Down Expand Up @@ -1330,7 +1331,7 @@ func callActorPathOnce(t *testing.T, actorRef resources.ActorRef, method, path s
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
reqHttp.Host = resources.ActorDNSName(actorRef)
reqHttp.Header.Set(atenet.TargetActorHeader, actorRef.String())

httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Do(reqHttp)
Expand Down
6 changes: 4 additions & 2 deletions internal/e2e/suites/networking/arbitraryport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"testing"
"time"

"github.com/agent-substrate/substrate/internal/atenet"
"github.com/agent-substrate/substrate/internal/e2e"
"github.com/agent-substrate/substrate/internal/resources"
)
Expand Down Expand Up @@ -106,7 +107,8 @@ func TestActorArbitraryPortAccess(t *testing.T) {
defer conn.Close()

conn.SetDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: " + resources.ActorDNSName(actorRef) + "\r\nConnection: close\r\n\r\n")); err != nil {
if _, err := fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\n%s: %s\r\nConnection: close\r\n\r\n",
actorRef.Name, atenet.TargetActorHeader, actorRef.String()); err != nil {
t.Fatalf("writing tunneled request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
Expand Down Expand Up @@ -140,7 +142,7 @@ func waitForTunneledRouteReady(t *testing.T, ctx context.Context, router *e2e.Ro
for {
conn, err := router.Connect(ctx, actorRef, port)
if err == nil {
resp, body, requestErr := requestTunneled(conn, resources.ActorDNSName(actorRef))
resp, body, requestErr := requestTunneled(conn, actorRef.Name)
_ = conn.Close()
if requestErr == nil && resp.StatusCode == http.StatusOK {
return body
Expand Down
17 changes: 10 additions & 7 deletions internal/e2e/suites/networking/grpcingress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import (

"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"

"github.com/agent-substrate/substrate/internal/ateclient"
"github.com/agent-substrate/substrate/internal/atenet"
"github.com/agent-substrate/substrate/internal/e2e"
"github.com/agent-substrate/substrate/internal/portforward"
"github.com/agent-substrate/substrate/internal/proto/grpcechopb"
Expand Down Expand Up @@ -72,7 +74,7 @@ func TestIngressProtocolDowngrade(t *testing.T) {
if err != nil {
return nil, err
}
req.Host = resources.ActorDNSName(actorRef)
req.Header.Set(atenet.TargetActorHeader, actorRef.String())
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
Expand Down Expand Up @@ -150,17 +152,18 @@ func TestIngressGRPC(t *testing.T) {
fixture := deployGRPCEchoTemplate(t, ctx, env["BUCKET_NAME"])
actorName, _ := createAndResumeSubstrateActor(t, ctx, "grpcingress", fixture)
actorRef := resources.ActorRef{Atespace: networkingAtespace, Name: actorName}
ctx = metadata.AppendToOutgoingContext(ctx,
atenet.TargetActorHeader, actorRef.String(),
)

// Cleartext h2c to the router's HTTP port, with the Actor's DNS name as the
// :authority — the same routing key every other ingress test in this suite
// uses, just carried by a gRPC client instead of an HTTP one. The h2 ALPN
// offer is about the *TLS* listener; nothing here needs it.
// Cleartext h2c to the router's HTTP port. Explicit metadata identifies the
// Actor; the conventional actor authority remains application metadata. The
// h2 ALPN offer is about the *TLS* listener; nothing here needs it.
conn, err := grpc.NewClient(routerAddress(t, ctx),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithAuthority(resources.ActorDNSName(actorRef)),
)
if err != nil {
t.Fatalf("creating the gRPC client for %s: %v", resources.ActorDNSName(actorRef), err)
t.Fatalf("creating the gRPC client for %s: %v", actorRef, err)
}
defer conn.Close()
client := grpcechopb.NewEchoClient(conn)
Expand Down
3 changes: 2 additions & 1 deletion internal/e2e/suites/networking/websocketingress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"testing"
"time"

"github.com/agent-substrate/substrate/internal/atenet"
"github.com/agent-substrate/substrate/internal/e2e"
"github.com/agent-substrate/substrate/internal/resources"
"github.com/gorilla/websocket"
Expand Down Expand Up @@ -67,7 +68,7 @@ func TestWebsocketIngressPing(t *testing.T) {

actorRef := resources.ActorRef{Atespace: networkingAtespace, Name: actorName}
header := http.Header{}
header.Set("Host", resources.ActorDNSName(actorRef))
header.Set(atenet.TargetActorHeader, actorRef.String())

var c *websocket.Conn

Expand Down
Loading