|
15 | 15 | package controlapi |
16 | 16 |
|
17 | 17 | import ( |
18 | | - "context" |
19 | 18 | "fmt" |
20 | | - "log/slog" |
21 | 19 | "net" |
22 | | - "sort" |
23 | | - "strconv" |
24 | | - "strings" |
25 | 20 |
|
26 | 21 | "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" |
29 | 26 | ) |
30 | 27 |
|
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 == "" { |
54 | 51 | continue |
55 | 52 | } |
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) |
80 | 55 | } |
| 56 | + return tier.address, nil |
81 | 57 | } |
82 | | - if best == nil { |
83 | | - return "", nil |
84 | | - } |
85 | | - return best.address, nil |
| 58 | + return "", nil |
86 | 59 | } |
87 | 60 |
|
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 |
91 | 69 | } |
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())} |
106 | 72 | } |
107 | | - return 0 |
| 73 | + return nil |
108 | 74 | } |
109 | 75 |
|
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 | +) |
155 | 86 |
|
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)} |
164 | 94 | } |
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)) |
176 | 98 | } |
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)) |
180 | 101 | } |
181 | | - return conditionStatus == "True", nil |
182 | 102 | } |
183 | | - return false, nil |
| 103 | + return errs |
184 | 104 | } |
185 | 105 |
|
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 |
192 | 114 | } |
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) |
210 | 116 | } |
211 | 117 |
|
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 == "" { |
230 | 125 | continue |
231 | 126 | } |
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{} |
238 | 129 | } |
239 | | - return port, true, nil |
| 130 | + out[k] = v |
240 | 131 | } |
241 | | - return 0, false, nil |
| 132 | + return out |
242 | 133 | } |
243 | 134 |
|
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) |
248 | 142 | } |
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) |
260 | 147 | } |
| 148 | + return nil |
261 | 149 | } |
0 commit comments