Skip to content

Commit d853537

Browse files
committed
move to substrate label
Signed-off-by: npolshakova <nina.polshakova@solo.io>
1 parent 41daefc commit d853537

30 files changed

Lines changed: 837 additions & 1200 deletions

File tree

cmd/ateapi/internal/controlapi/create_actor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func (s *Service) CreateActor(ctx context.Context, req *ateapipb.CreateActorRequ
5959
ActorTemplateName: req.GetActorTemplateName(),
6060
WorkerSelector: req.GetWorkerSelector(),
6161
Atespace: req.GetActorRef().GetAtespace(),
62+
Labels: normalizeLabels(req.GetLabels()),
6263
}
6364
err = s.persistence.CreateActor(ctx, actor)
6465
if err != nil {
@@ -108,6 +109,9 @@ func validateCreateActorRequest(req *ateapipb.CreateActorRequest) error {
108109
errs = append(errs, validateSelector(val, fldPath.Child("worker_selector"))...)
109110
}
110111

112+
errs = append(errs, validateLabels(req.GetLabels(), fldPath.Child("labels"))...)
113+
errs = append(errs, validateEgressPEPSelector(req.GetLabels(), fldPath.Child("labels"))...)
114+
111115
if len(errs) > 0 {
112116
return status.Error(codes.InvalidArgument, errs.ToAggregate().Error())
113117
}

cmd/ateapi/internal/controlapi/create_atespace.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import (
2424
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
2525
"google.golang.org/grpc/codes"
2626
"google.golang.org/grpc/status"
27+
"k8s.io/apimachinery/pkg/util/validation/field"
2728
)
2829

2930
func (s *Service) CreateAtespace(ctx context.Context, req *ateapipb.CreateAtespaceRequest) (*ateapipb.CreateAtespaceResponse, error) {
3031
if err := validateCreateAtespaceRequest(req); err != nil {
3132
return nil, err
3233
}
3334

34-
atespace := &ateapipb.Atespace{Name: req.GetName()}
35+
atespace := &ateapipb.Atespace{Name: req.GetName(), Labels: normalizeLabels(req.GetLabels())}
3536
if err := s.persistence.CreateAtespace(ctx, atespace); err != nil {
3637
if errors.Is(err, store.ErrAlreadyExists) {
3738
return nil, status.Errorf(codes.AlreadyExists, "Atespace %s already exists", req.GetName())
@@ -49,5 +50,11 @@ func validateCreateAtespaceRequest(req *ateapipb.CreateAtespaceRequest) error {
4950
if err := resources.ValidateAtespace(req.GetName()); err != nil {
5051
return status.Error(codes.InvalidArgument, err.Error())
5152
}
53+
var errs field.ErrorList
54+
errs = append(errs, validateLabels(req.GetLabels(), field.NewPath("labels"))...)
55+
errs = append(errs, validateEgressPEPSelector(req.GetLabels(), field.NewPath("labels"))...)
56+
if len(errs) > 0 {
57+
return status.Error(codes.InvalidArgument, errs.ToAggregate().Error())
58+
}
5259
return nil
5360
}

cmd/ateapi/internal/controlapi/egress_pep.go

Lines changed: 97 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -15,247 +15,135 @@
1515
package controlapi
1616

1717
import (
18-
"context"
1918
"fmt"
20-
"log/slog"
2119
"net"
22-
"sort"
23-
"strconv"
24-
"strings"
2520

2621
"github.com/agent-substrate/substrate/internal/egress"
27-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
28-
"k8s.io/client-go/tools/cache"
22+
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
23+
"k8s.io/apimachinery/pkg/api/validate/content"
24+
"k8s.io/apimachinery/pkg/util/validation/field"
25+
netutils "k8s.io/utils/net"
2926
)
3027

31-
type egressPEPGateway struct {
32-
namespace string
33-
name string
34-
address string
35-
labels map[string]string
36-
}
37-
38-
func resolveEgressPEPAddress(ctx context.Context, gatewayIndexer cache.Indexer, atespace, actorID string) (string, error) {
39-
if gatewayIndexer == nil {
40-
return "", nil
41-
}
42-
43-
var candidates []egressPEPGateway
44-
for _, obj := range gatewayIndexer.List() {
45-
u, ok := obj.(*unstructured.Unstructured)
46-
if !ok {
47-
return "", fmt.Errorf("egress PEP cache contained %T, want *unstructured.Unstructured", obj)
48-
}
49-
pep, ok, err := egressPEPGatewayFromUnstructured(u)
50-
if err != nil {
51-
return "", err
52-
}
53-
if !ok {
28+
// resolveEgressPEPAddress selects the egress PEP address for an actor by
29+
// consumer-driven precedence, following the Istio ambient istio.io/use-waypoint
30+
// model: the actor's own selector wins, then the atespace's, then the global
31+
// default. Each tier supplies the PEP address directly as "<host>:<port>" via
32+
// the ate.dev/use-egress-pep label (the global default comes from the
33+
// --default-egress-pep flag).
34+
//
35+
// ate-api has no dependency on the Gateway API: it does not look up, watch, or
36+
// validate any Gateway resource. The selected address is passed to ateom as
37+
// given. An empty result means no PEP selected and egress capture stays off. A
38+
// malformed address is a configuration error and fails resolution loudly.
39+
func resolveEgressPEPAddress(actor *ateapipb.Actor, atespace *ateapipb.Atespace, defaultAddr string) (string, error) {
40+
tiers := []struct {
41+
source string
42+
address string
43+
}{
44+
{"actor", actor.GetLabels()[egress.LabelUseEgressPEP]},
45+
{"atespace", atespace.GetLabels()[egress.LabelUseEgressPEP]},
46+
{"global", defaultAddr},
47+
}
48+
49+
for _, tier := range tiers {
50+
if tier.address == "" {
5451
continue
5552
}
56-
// An actor-scoped PEP needs both labels: the actor label alone scores 0
57-
// for every actor (egressPEPGatewayScore requires the atespace to match
58-
// too), so the Gateway silently matches nothing. Warn instead of failing
59-
// so the actor still falls back to the next-best PEP.
60-
if pep.labels[egress.LabelActor] != "" && pep.labels[egress.LabelAtespace] == "" {
61-
slog.WarnContext(ctx, "Egress PEP Gateway has an actor label but no atespace label; it can never match any actor",
62-
"gateway", pep.namespace+"/"+pep.name)
63-
}
64-
candidates = append(candidates, pep)
65-
}
66-
67-
bestScore := 0
68-
var best *egressPEPGateway
69-
sort.Slice(candidates, func(i, j int) bool {
70-
if candidates[i].namespace != candidates[j].namespace {
71-
return candidates[i].namespace < candidates[j].namespace
72-
}
73-
return candidates[i].name < candidates[j].name
74-
})
75-
for i := range candidates {
76-
score := egressPEPGatewayScore(candidates[i].labels, atespace, actorID)
77-
if score > bestScore {
78-
bestScore = score
79-
best = &candidates[i]
53+
if err := validatePEPAddress(tier.address); err != nil {
54+
return "", fmt.Errorf("%s egress PEP: %w", tier.source, err)
8055
}
56+
return tier.address, nil
8157
}
82-
if best == nil {
83-
return "", nil
84-
}
85-
return best.address, nil
58+
return "", nil
8659
}
8760

88-
func egressPEPGatewayScore(labels map[string]string, atespace, actorID string) int {
89-
if _, ok := labels[egress.LabelPEP]; !ok {
90-
return 0
61+
// validateEgressPEPSelector validates the ate.dev/use-egress-pep selector label
62+
// (if present and non-empty) parses as "<host>:<port>". An absent key means no
63+
// selector; an explicit empty value also means no selector — resolution skips
64+
// empty tiers, and on UpdateActor an empty value deletes the key.
65+
func validateEgressPEPSelector(labels map[string]string, fldPath *field.Path) field.ErrorList {
66+
address := labels[egress.LabelUseEgressPEP]
67+
if address == "" {
68+
return nil
9169
}
92-
93-
pepAtespace := labels[egress.LabelAtespace]
94-
pepActor := labels[egress.LabelActor]
95-
switch {
96-
case pepActor != "":
97-
if pepActor == actorID && pepAtespace == atespace {
98-
return 3
99-
}
100-
case pepAtespace != "":
101-
if pepAtespace == atespace {
102-
return 2
103-
}
104-
default:
105-
return 1
70+
if err := validatePEPAddress(address); err != nil {
71+
return field.ErrorList{field.Invalid(fldPath.Key(egress.LabelUseEgressPEP), address, err.Error())}
10672
}
107-
return 0
73+
return nil
10874
}
10975

110-
func egressPEPGatewayFromUnstructured(u *unstructured.Unstructured) (egressPEPGateway, bool, error) {
111-
labels := u.GetLabels()
112-
if _, ok := labels[egress.LabelPEP]; !ok {
113-
return egressPEPGateway{}, false, nil
114-
}
115-
116-
// Only Programmed Gateways are candidates: an unprovisioned dataplane has no
117-
// working address, and selecting it would hand ateom a dead PEP. Skipping
118-
// (rather than erroring) lets resolution fall back to the next-best PEP
119-
// while a Gateway is still being reconciled.
120-
programmed, err := gatewayProgrammed(u)
121-
if err != nil {
122-
return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err)
123-
}
124-
if !programmed {
125-
return egressPEPGateway{}, false, nil
126-
}
127-
128-
port, ok, err := gatewayHTTPListenerPort(u)
129-
if err != nil {
130-
return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err)
131-
}
132-
if !ok {
133-
return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s has no HTTP listener", u.GetNamespace(), u.GetName())
134-
}
135-
136-
// Prefer the address the Gateway implementation published in
137-
// status.addresses; fall back to the agentgateway convention of a Service
138-
// named after the Gateway when the implementation publishes none (e.g.
139-
// agentgateway on kind, where the LoadBalancer address stays pending).
140-
host, err := gatewayStatusAddress(u)
141-
if err != nil {
142-
return egressPEPGateway{}, false, fmt.Errorf("egress PEP Gateway %s/%s: %w", u.GetNamespace(), u.GetName(), err)
143-
}
144-
if host == "" {
145-
host = fmt.Sprintf("%s.%s.svc.cluster.local", u.GetName(), u.GetNamespace())
146-
}
147-
148-
return egressPEPGateway{
149-
namespace: u.GetNamespace(),
150-
name: u.GetName(),
151-
address: net.JoinHostPort(host, strconv.FormatInt(port, 10)),
152-
labels: labels,
153-
}, true, nil
154-
}
76+
const (
77+
// maxLabels bounds the number of selector-label entries on an actor or
78+
// atespace, mirroring maxSelectorMatchLabels for worker selectors.
79+
maxLabels = 10
80+
// maxLabelValueLength bounds label values. Values are not restricted to the
81+
// Kubernetes label-value charset because the egress PEP selector carries a
82+
// "<host>:<port>" address; the cap allows a maximal DNS name (253) plus a
83+
// colon and port with margin.
84+
maxLabelValueLength = 320
85+
)
15586

156-
// gatewayProgrammed reports whether the Gateway has condition Programmed=True.
157-
func gatewayProgrammed(u *unstructured.Unstructured) (bool, error) {
158-
conditions, ok, err := unstructured.NestedSlice(u.Object, "status", "conditions")
159-
if err != nil {
160-
return false, fmt.Errorf("read status.conditions: %w", err)
161-
}
162-
if !ok {
163-
return false, nil
87+
// validateLabels bounds a request's selector-label map: entry count, Kubernetes
88+
// label-key syntax, and value length. Value contents beyond length are only
89+
// checked for keys with dedicated validators (validateEgressPEPSelector).
90+
func validateLabels(labels map[string]string, fldPath *field.Path) field.ErrorList {
91+
var errs field.ErrorList
92+
if n := len(labels); n > maxLabels {
93+
return field.ErrorList{field.TooMany(fldPath, n, maxLabels)}
16494
}
165-
for _, condition := range conditions {
166-
m, ok := condition.(map[string]any)
167-
if !ok {
168-
return false, fmt.Errorf("condition had type %T, want map[string]any", condition)
169-
}
170-
conditionType, _, err := unstructured.NestedString(m, "type")
171-
if err != nil {
172-
return false, fmt.Errorf("read condition type: %w", err)
173-
}
174-
if conditionType != "Programmed" {
175-
continue
95+
for k, v := range labels {
96+
for _, msg := range content.IsLabelKey(k) {
97+
errs = append(errs, field.Invalid(fldPath.Key(k), k, msg))
17698
}
177-
conditionStatus, _, err := unstructured.NestedString(m, "status")
178-
if err != nil {
179-
return false, fmt.Errorf("read condition status: %w", err)
99+
if len(v) > maxLabelValueLength {
100+
errs = append(errs, field.TooLong(fldPath.Key(k), v, maxLabelValueLength))
180101
}
181-
return conditionStatus == "True", nil
182102
}
183-
return false, nil
103+
return errs
184104
}
185105

186-
// gatewayStatusAddress returns the first address the Gateway implementation
187-
// published in status.addresses, or "" if none is published.
188-
func gatewayStatusAddress(u *unstructured.Unstructured) (string, error) {
189-
addresses, ok, err := unstructured.NestedSlice(u.Object, "status", "addresses")
190-
if err != nil {
191-
return "", fmt.Errorf("read status.addresses: %w", err)
106+
// ValidateDefaultEgressPEPAddress validates the global-default egress PEP address
107+
// (the --default-egress-pep flag) at boot. Empty is allowed (no global default).
108+
// A malformed non-empty address is a configuration error the caller surfaces so
109+
// a typo does not silently reach ateom and fail every actor resume that falls
110+
// through to the global tier; ate-api logs it and degrades to no global default.
111+
func ValidateDefaultEgressPEPAddress(address string) error {
112+
if address == "" {
113+
return nil
192114
}
193-
if !ok {
194-
return "", nil
195-
}
196-
for _, address := range addresses {
197-
m, ok := address.(map[string]any)
198-
if !ok {
199-
return "", fmt.Errorf("address had type %T, want map[string]any", address)
200-
}
201-
value, _, err := unstructured.NestedString(m, "value")
202-
if err != nil {
203-
return "", fmt.Errorf("read address value: %w", err)
204-
}
205-
if value != "" {
206-
return value, nil
207-
}
208-
}
209-
return "", nil
115+
return validatePEPAddress(address)
210116
}
211117

212-
func gatewayHTTPListenerPort(u *unstructured.Unstructured) (int64, bool, error) {
213-
listeners, ok, err := unstructured.NestedSlice(u.Object, "spec", "listeners")
214-
if err != nil {
215-
return 0, false, fmt.Errorf("read spec.listeners: %w", err)
216-
}
217-
if !ok {
218-
return 0, false, nil
219-
}
220-
for _, listener := range listeners {
221-
m, ok := listener.(map[string]any)
222-
if !ok {
223-
return 0, false, fmt.Errorf("listener had type %T, want map[string]any", listener)
224-
}
225-
protocol, _, err := unstructured.NestedString(m, "protocol")
226-
if err != nil {
227-
return 0, false, fmt.Errorf("read listener protocol: %w", err)
228-
}
229-
if !strings.EqualFold(protocol, "HTTP") {
118+
// normalizeLabels returns a copy of labels without empty-valued entries (an
119+
// empty value means "no selector"), or nil when nothing remains. Create paths
120+
// use it so an explicit empty selector is stored the same as an absent one.
121+
func normalizeLabels(labels map[string]string) map[string]string {
122+
var out map[string]string
123+
for k, v := range labels {
124+
if v == "" {
230125
continue
231126
}
232-
port, ok, err := listenerPort(m)
233-
if err != nil {
234-
return 0, false, err
235-
}
236-
if !ok {
237-
return 0, false, fmt.Errorf("HTTP listener has no port")
127+
if out == nil {
128+
out = map[string]string{}
238129
}
239-
return port, true, nil
130+
out[k] = v
240131
}
241-
return 0, false, nil
132+
return out
242133
}
243134

244-
func listenerPort(listener map[string]any) (int64, bool, error) {
245-
port, ok := listener["port"]
246-
if !ok {
247-
return 0, false, nil
135+
// validatePEPAddress checks that an egress PEP address is a "<host>:<port>" pair
136+
// with a numeric port in range. ate-api does not verify the host resolves or
137+
// that a Gateway actually serves it — the address is used as given.
138+
func validatePEPAddress(address string) error {
139+
host, port, err := net.SplitHostPort(address)
140+
if err != nil || host == "" || port == "" {
141+
return fmt.Errorf("egress PEP address %q must be in the form <host>:<port>", address)
248142
}
249-
switch v := port.(type) {
250-
case int64:
251-
return v, true, nil
252-
case int32:
253-
return int64(v), true, nil
254-
case int:
255-
return int64(v), true, nil
256-
case float64:
257-
return int64(v), true, nil
258-
default:
259-
return 0, false, fmt.Errorf("HTTP listener port had type %T, want integer", port)
143+
// ParsePort rejects sign prefixes ("+80") and out-of-range ports that
144+
// SplitHostPort passes through unchecked.
145+
if _, err := netutils.ParsePort(port, false); err != nil {
146+
return fmt.Errorf("egress PEP address %q has an invalid port %q", address, port)
260147
}
148+
return nil
261149
}

0 commit comments

Comments
 (0)