Skip to content
Open
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
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ build-atenet:

.PHONY: build-demos
build-demos:
$(KO) build --ldflags="$(LDFLAGS)" ./demos/counter
GOFLAGS='"-ldflags=$(LDFLAGS)"' \
$(KO) build ./demos/counter ./demos/egress

.PHONY: test
test:
Expand Down
4 changes: 4 additions & 0 deletions cmd/ateapi/internal/controlapi/create_actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequ
ActorTemplateNamespace: templateNamespace,
ActorTemplateName: templateName,
WorkerSelector: in.GetWorkerSelector(),
Labels: normalizeLabels(in.GetLabels()),
}
stored, err := s.persistence.CreateActor(ctx, actor)
if err != nil {
Expand Down Expand Up @@ -121,6 +122,9 @@ func validateCreateActorRequest(req *ateapipb.CreateActorRequest) error {
errs = append(errs, validateSelector(val, actorPath.Child("worker_selector"))...)
}

errs = append(errs, validateLabels(actor.GetLabels(), actorPath.Child("labels"))...)
errs = append(errs, validateEgressPEPSelector(actor.GetLabels(), actorPath.Child("labels"))...)

if len(errs) > 0 {
return status.Error(codes.InvalidArgument, errs.ToAggregate().Error())
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/ateapi/internal/controlapi/create_atespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func (s *Service) CreateAtespace(ctx context.Context, req *ateapipb.CreateAtespa
Metadata: &ateapipb.ResourceMetadata{
Name: name,
},
Labels: normalizeLabels(req.GetAtespace().GetLabels()),
}
stored, err := s.persistence.CreateAtespace(ctx, atespace)
if err != nil {
Expand Down Expand Up @@ -71,6 +72,8 @@ func validateCreateAtespaceRequest(req *ateapipb.CreateAtespaceRequest) error {
errs = append(errs, resources.ValidateResourceName(val, p)...)
}

errs = append(errs, validateLabels(atespace.GetLabels(), atespacePath.Child("labels"))...)
errs = append(errs, validateEgressPEPSelector(atespace.GetLabels(), atespacePath.Child("labels"))...)
if len(errs) > 0 {
return status.Error(codes.InvalidArgument, errs.ToAggregate().Error())
}
Expand Down
149 changes: 149 additions & 0 deletions cmd/ateapi/internal/controlapi/egress_pep.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// 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 controlapi

import (
"fmt"
"net"

"github.com/agent-substrate/substrate/internal/egress"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"k8s.io/apimachinery/pkg/api/validate/content"
"k8s.io/apimachinery/pkg/util/validation/field"
netutils "k8s.io/utils/net"
)

// resolveEgressPEPAddress selects the egress PEP address for an actor by
// consumer-driven precedence, following the Istio ambient istio.io/use-waypoint
// model: the actor's own selector wins, then the atespace's, then the global
// default. Each tier supplies the PEP address directly as "<host>:<port>" via
// the ate.dev/use-egress-pep label (the global default comes from the
// --default-egress-pep flag).
//
// ate-api has no dependency on the Gateway API: it does not look up, watch, or
// validate any Gateway resource. The selected address is passed to ateom as
// given. An empty result means no PEP selected and egress capture stays off. A
// malformed address is a configuration error and fails resolution loudly.
func resolveEgressPEPAddress(actor *ateapipb.Actor, atespace *ateapipb.Atespace, defaultAddr string) (string, error) {
tiers := []struct {
source string
address string
}{
{"actor", actor.GetLabels()[egress.LabelUseEgressPEP]},
{"atespace", atespace.GetLabels()[egress.LabelUseEgressPEP]},
{"global", defaultAddr},
}

for _, tier := range tiers {
if tier.address == "" {
continue
}
if err := validatePEPAddress(tier.address); err != nil {
return "", fmt.Errorf("%s egress PEP: %w", tier.source, err)
}
return tier.address, nil
}
return "", nil
}

// validateEgressPEPSelector validates the ate.dev/use-egress-pep selector label
// (if present and non-empty) parses as "<host>:<port>". An absent key means no
// selector; an explicit empty value also means no selector — resolution skips
// empty tiers, and on UpdateActor an empty value deletes the key.
func validateEgressPEPSelector(labels map[string]string, fldPath *field.Path) field.ErrorList {
address := labels[egress.LabelUseEgressPEP]
if address == "" {
return nil
}
if err := validatePEPAddress(address); err != nil {
return field.ErrorList{field.Invalid(fldPath.Key(egress.LabelUseEgressPEP), address, err.Error())}
}
return nil
}

const (
// maxLabels bounds the number of selector-label entries on an actor or
// atespace, mirroring maxSelectorMatchLabels for worker selectors.
maxLabels = 10
// maxLabelValueLength bounds label values. Values are not restricted to the
// Kubernetes label-value charset because the egress PEP selector carries a
// "<host>:<port>" address; the cap allows a maximal DNS name (253) plus a
// colon and port with margin.
maxLabelValueLength = 320
)

// validateLabels bounds a request's selector-label map: entry count, Kubernetes
// label-key syntax, and value length. Value contents beyond length are only
// checked for keys with dedicated validators (validateEgressPEPSelector).
func validateLabels(labels map[string]string, fldPath *field.Path) field.ErrorList {
var errs field.ErrorList
if n := len(labels); n > maxLabels {
return field.ErrorList{field.TooMany(fldPath, n, maxLabels)}
}
for k, v := range labels {
for _, msg := range content.IsLabelKey(k) {
errs = append(errs, field.Invalid(fldPath.Key(k), k, msg))
}
if len(v) > maxLabelValueLength {
errs = append(errs, field.TooLong(fldPath.Key(k), v, maxLabelValueLength))
}
}
return errs
}

// ValidateDefaultEgressPEPAddress validates the global-default egress PEP address
// (the --default-egress-pep flag) at boot. Empty is allowed (no global default).
// A malformed non-empty address is a configuration error the caller surfaces so
// a typo does not silently reach ateom and fail every actor resume that falls
// through to the global tier; ate-api logs it and degrades to no global default.
func ValidateDefaultEgressPEPAddress(address string) error {
if address == "" {
return nil
}
return validatePEPAddress(address)
}

// normalizeLabels returns a copy of labels without empty-valued entries (an
// empty value means "no selector"), or nil when nothing remains. Create paths
// use it so an explicit empty selector is stored the same as an absent one.
func normalizeLabels(labels map[string]string) map[string]string {
var out map[string]string
for k, v := range labels {
if v == "" {
continue
}
if out == nil {
out = map[string]string{}
}
out[k] = v
}
return out
}

// validatePEPAddress checks that an egress PEP address is a "<host>:<port>" pair
// with a numeric port in range. ate-api does not verify the host resolves or
// that a Gateway actually serves it — the address is used as given.
func validatePEPAddress(address string) error {
host, port, err := net.SplitHostPort(address)
if err != nil || host == "" || port == "" {
return fmt.Errorf("egress PEP address %q must be in the form <host>:<port>", address)
}
// ParsePort rejects sign prefixes ("+80") and out-of-range ports that
// SplitHostPort passes through unchecked.
if _, err := netutils.ParsePort(port, false); err != nil {
return fmt.Errorf("egress PEP address %q has an invalid port %q", address, port)
}
return nil
}
162 changes: 162 additions & 0 deletions cmd/ateapi/internal/controlapi/egress_pep_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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 controlapi

import (
"fmt"
"strings"
"testing"

"github.com/agent-substrate/substrate/internal/egress"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)

func actorWithPEP(address string) *ateapipb.Actor {
a := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: "space-a",
Name: "actor-a",
},
}
if address != "" {
a.Labels = map[string]string{egress.LabelUseEgressPEP: address}
}
return a
}

func atespaceWithPEP(address string) *ateapipb.Atespace {
s := &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "space-a"}}
if address != "" {
s.Labels = map[string]string{egress.LabelUseEgressPEP: address}
}
return s
}

func TestResolveEgressPEPAddressNoSelector(t *testing.T) {
got, err := resolveEgressPEPAddress(actorWithPEP(""), atespaceWithPEP(""), "")
if err != nil {
t.Fatalf("resolveEgressPEPAddress() error = %v", err)
}
if got != "" {
t.Fatalf("resolveEgressPEPAddress() = %q, want empty", got)
}
}

func TestResolveEgressPEPAddressPrefersActorThenAtespaceThenGlobal(t *testing.T) {
const (
actorPEP = "ate-egress-actor.agentgateway-system.svc.cluster.local:15008"
spacePEP = "ate-egress-space.agentgateway-system.svc.cluster.local:15008"
globalPEP = "ate-egress.agentgateway-system.svc.cluster.local:15008"
)

// Actor selector wins over atespace and global default.
got, err := resolveEgressPEPAddress(actorWithPEP(actorPEP), atespaceWithPEP(spacePEP), globalPEP)
if err != nil {
t.Fatalf("resolveEgressPEPAddress() error = %v", err)
}
if got != actorPEP {
t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, actorPEP)
}

// No actor selector: atespace wins over global default.
got, err = resolveEgressPEPAddress(actorWithPEP(""), atespaceWithPEP(spacePEP), globalPEP)
if err != nil {
t.Fatalf("resolveEgressPEPAddress() error = %v", err)
}
if got != spacePEP {
t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, spacePEP)
}

// No actor or atespace selector: global default is used.
got, err = resolveEgressPEPAddress(actorWithPEP(""), atespaceWithPEP(""), globalPEP)
if err != nil {
t.Fatalf("resolveEgressPEPAddress() error = %v", err)
}
if got != globalPEP {
t.Fatalf("resolveEgressPEPAddress() = %q, want %q", got, globalPEP)
}
}

func TestResolveEgressPEPAddressMalformedErrors(t *testing.T) {
if _, err := resolveEgressPEPAddress(actorWithPEP("no-port-here"), atespaceWithPEP(""), ""); err == nil {
t.Fatal("resolveEgressPEPAddress() error = nil, want error for malformed address")
}
}

func TestValidateDefaultEgressPEPAddress(t *testing.T) {
// Empty is allowed: no global default configured.
if err := ValidateDefaultEgressPEPAddress(""); err != nil {
t.Fatalf("ValidateDefaultEgressPEPAddress(\"\") error = %v, want nil", err)
}
if err := ValidateDefaultEgressPEPAddress("ate-egress.agentgateway-system.svc.cluster.local:15008"); err != nil {
t.Fatalf("ValidateDefaultEgressPEPAddress() valid address error = %v", err)
}
if err := ValidateDefaultEgressPEPAddress("no-port-here"); err == nil {
t.Fatal("ValidateDefaultEgressPEPAddress() malformed address error = nil, want error")
}
}

func TestValidateEgressPEPSelector(t *testing.T) {
if errs := validateEgressPEPSelector(map[string]string{egress.LabelUseEgressPEP: "pep.example:15008"}, nil); len(errs) != 0 {
t.Fatalf("validateEgressPEPSelector() valid address returned errors: %v", errs)
}
if errs := validateEgressPEPSelector(map[string]string{egress.LabelUseEgressPEP: "missing-port"}, nil); len(errs) == 0 {
t.Fatal("validateEgressPEPSelector() malformed address returned no errors")
}
// A signed port must be rejected (ParsePort, unlike Atoi, refuses "+80").
if errs := validateEgressPEPSelector(map[string]string{egress.LabelUseEgressPEP: "pep.example:+80"}, nil); len(errs) == 0 {
t.Fatal("validateEgressPEPSelector() signed port returned no errors")
}
if errs := validateEgressPEPSelector(nil, nil); len(errs) != 0 {
t.Fatalf("validateEgressPEPSelector() absent label returned errors: %v", errs)
}
// An explicit empty value means "no selector" (delete on UpdateActor) and
// must be accepted.
if errs := validateEgressPEPSelector(map[string]string{egress.LabelUseEgressPEP: ""}, nil); len(errs) != 0 {
t.Fatalf("validateEgressPEPSelector() empty value returned errors: %v", errs)
}
}

func TestValidateLabels(t *testing.T) {
if errs := validateLabels(map[string]string{"team": "a", egress.LabelUseEgressPEP: "pep.example:15008"}, nil); len(errs) != 0 {
t.Fatalf("validateLabels() valid labels returned errors: %v", errs)
}
if errs := validateLabels(map[string]string{"not a label key!": "v"}, nil); len(errs) == 0 {
t.Fatal("validateLabels() invalid key returned no errors")
}
if errs := validateLabels(map[string]string{"k": strings.Repeat("v", maxLabelValueLength+1)}, nil); len(errs) == 0 {
t.Fatal("validateLabels() oversized value returned no errors")
}
tooMany := map[string]string{}
for i := 0; i <= maxLabels; i++ {
tooMany[fmt.Sprintf("key-%d", i)] = "v"
}
if errs := validateLabels(tooMany, nil); len(errs) == 0 {
t.Fatal("validateLabels() too many entries returned no errors")
}
}

func TestNormalizeLabels(t *testing.T) {
got := normalizeLabels(map[string]string{"keep": "v", "drop": ""})
if len(got) != 1 || got["keep"] != "v" {
t.Fatalf("normalizeLabels() = %v, want only the non-empty entry", got)
}
if got := normalizeLabels(map[string]string{"drop": ""}); got != nil {
t.Fatalf("normalizeLabels() all-empty = %v, want nil", got)
}
if got := normalizeLabels(nil); got != nil {
t.Fatalf("normalizeLabels(nil) = %v, want nil", got)
}
}
Loading